diff --git a/messages/de.json b/messages/de.json index 68ee1bc1b..73d71cc90 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9472,6 +9472,31 @@ "opened": "{name} hat deine Akte {count}× geöffnet", "acted": "{name} hat etwas in deiner Akte geändert", "deletedAccount": "Ein gelöschtes Konto" + }, + "delegate": { + "addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}." + }, + "lookingAfter": { + "title": "Ich kümmere mich um", + "description": "Akten, die andere mit dir geteilt haben.", + "levelRead": "Darf lesen", + "levelWrite": "Darf lesen und hinzufügen", + "open": "Akte von {name} öffnen" + }, + "toast": { + "savedTo": "In der Akte von {name} gespeichert." + }, + "activityVerb": { + "measurementCreate": "{name} hat einen Messwert eingetragen", + "labResultCreate": "{name} hat einen Laborwert eingetragen", + "allergyCreate": "{name} hat eine Allergie eingetragen", + "familyHistoryCreate": "{name} hat einen Eintrag zur Familienanamnese angelegt", + "illnessEpisodeCreate": "{name} hat eine Erkrankung angelegt", + "biomarkerCreate": "{name} hat einen Biomarker angelegt", + "customMetricEntryCreate": "{name} hat einen eigenen Messwert eingetragen", + "medicationSideEffectCreate": "{name} hat eine Nebenwirkung notiert", + "medicationCreate": "{name} hat ein Medikament angelegt", + "medicationIntake": "{name} hat eine Dosis eingetragen" } } } diff --git a/messages/en.json b/messages/en.json index eaa97402e..e5f81ce90 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9472,6 +9472,31 @@ "opened": "{name} opened your record {count}×", "acted": "{name} made a change in your record", "deletedAccount": "A deleted account" + }, + "delegate": { + "addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do." + }, + "lookingAfter": { + "title": "Looking after", + "description": "Records other people have shared with you.", + "levelRead": "Can view", + "levelWrite": "Can view and add", + "open": "Open {name}'s record" + }, + "toast": { + "savedTo": "Saved to {name}'s record." + }, + "activityVerb": { + "measurementCreate": "{name} added a reading", + "labResultCreate": "{name} added a lab result", + "allergyCreate": "{name} added an allergy", + "familyHistoryCreate": "{name} added a family history entry", + "illnessEpisodeCreate": "{name} added an illness entry", + "biomarkerCreate": "{name} added a biomarker", + "customMetricEntryCreate": "{name} logged a tracked value", + "medicationSideEffectCreate": "{name} noted a side effect", + "medicationCreate": "{name} added a medication", + "medicationIntake": "{name} marked a dose" } } } diff --git a/messages/es.json b/messages/es.json index 6b6cc1188..913c9955d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9472,6 +9472,31 @@ "opened": "{name} abrió tu historial {count}×", "acted": "{name} hizo un cambio en tu historial", "deletedAccount": "Una cuenta eliminada" + }, + "delegate": { + "addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}." + }, + "lookingAfter": { + "title": "Estoy cuidando de", + "description": "Historiales que otras personas han compartido contigo.", + "levelRead": "Puede ver", + "levelWrite": "Puede ver y añadir", + "open": "Abrir el historial de {name}" + }, + "toast": { + "savedTo": "Guardado en el historial de {name}." + }, + "activityVerb": { + "measurementCreate": "{name} añadió una medición", + "labResultCreate": "{name} añadió un resultado de laboratorio", + "allergyCreate": "{name} añadió una alergia", + "familyHistoryCreate": "{name} añadió un antecedente familiar", + "illnessEpisodeCreate": "{name} añadió una enfermedad", + "biomarkerCreate": "{name} añadió un biomarcador", + "customMetricEntryCreate": "{name} registró un valor propio", + "medicationSideEffectCreate": "{name} anotó un efecto secundario", + "medicationCreate": "{name} añadió un medicamento", + "medicationIntake": "{name} registró una dosis" } } } diff --git a/messages/fr.json b/messages/fr.json index 6c6f91a58..68fe8eeae 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9472,6 +9472,31 @@ "opened": "{name} a ouvert votre dossier {count}×", "acted": "{name} a fait une modification dans votre dossier", "deletedAccount": "Un compte supprimé" + }, + "delegate": { + "addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées." + }, + "lookingAfter": { + "title": "Je m'occupe de", + "description": "Les dossiers que d'autres personnes partagent avec vous.", + "levelRead": "Peut consulter", + "levelWrite": "Peut consulter et ajouter", + "open": "Ouvrir le dossier de {name}" + }, + "toast": { + "savedTo": "Enregistré dans le dossier de {name}." + }, + "activityVerb": { + "measurementCreate": "{name} a ajouté une mesure", + "labResultCreate": "{name} a ajouté un résultat de laboratoire", + "allergyCreate": "{name} a ajouté une allergie", + "familyHistoryCreate": "{name} a ajouté un antécédent familial", + "illnessEpisodeCreate": "{name} a ajouté une maladie", + "biomarkerCreate": "{name} a ajouté un biomarqueur", + "customMetricEntryCreate": "{name} a enregistré une valeur personnalisée", + "medicationSideEffectCreate": "{name} a noté un effet indésirable", + "medicationCreate": "{name} a ajouté un médicament", + "medicationIntake": "{name} a enregistré une prise" } } } diff --git a/messages/it.json b/messages/it.json index d6e1847c4..db367b72f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9472,6 +9472,31 @@ "opened": "{name} ha aperto la tua cartella {count}×", "acted": "{name} ha fatto una modifica nella tua cartella", "deletedAccount": "Un account eliminato" + }, + "delegate": { + "addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle." + }, + "lookingAfter": { + "title": "Mi prendo cura di", + "description": "Cartelle che altre persone hanno condiviso con te.", + "levelRead": "Può consultare", + "levelWrite": "Può consultare e aggiungere", + "open": "Apri la cartella di {name}" + }, + "toast": { + "savedTo": "Salvato nella cartella di {name}." + }, + "activityVerb": { + "measurementCreate": "{name} ha aggiunto una misurazione", + "labResultCreate": "{name} ha aggiunto un risultato di laboratorio", + "allergyCreate": "{name} ha aggiunto un'allergia", + "familyHistoryCreate": "{name} ha aggiunto una voce di anamnesi familiare", + "illnessEpisodeCreate": "{name} ha aggiunto una malattia", + "biomarkerCreate": "{name} ha aggiunto un biomarcatore", + "customMetricEntryCreate": "{name} ha registrato un valore personalizzato", + "medicationSideEffectCreate": "{name} ha annotato un effetto collaterale", + "medicationCreate": "{name} ha aggiunto un farmaco", + "medicationIntake": "{name} ha registrato una dose" } } } diff --git a/messages/pl.json b/messages/pl.json index 7ca42645b..321e778b2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9472,6 +9472,31 @@ "opened": "{name} otworzył twoją kartę {count}×", "acted": "{name} wprowadził zmianę w twojej karcie", "deletedAccount": "Usunięte konto" + }, + "delegate": { + "addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}." + }, + "lookingAfter": { + "title": "Opiekuję się", + "description": "Karty, które udostępniły ci inne osoby.", + "levelRead": "Może przeglądać", + "levelWrite": "Może przeglądać i dodawać", + "open": "Otwórz kartę użytkownika {name}" + }, + "toast": { + "savedTo": "Zapisano w karcie użytkownika {name}." + }, + "activityVerb": { + "measurementCreate": "{name} dodał pomiar", + "labResultCreate": "{name} dodał wynik badania", + "allergyCreate": "{name} dodał alergię", + "familyHistoryCreate": "{name} dodał wpis w wywiadzie rodzinnym", + "illnessEpisodeCreate": "{name} dodał chorobę", + "biomarkerCreate": "{name} dodał biomarker", + "customMetricEntryCreate": "{name} zapisał własny pomiar", + "medicationSideEffectCreate": "{name} zanotował działanie niepożądane", + "medicationCreate": "{name} dodał lek", + "medicationIntake": "{name} zapisał przyjęcie dawki" } } } diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 75d1669f8..23bc53a73 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -256,7 +256,7 @@ const PINNED_AFFORDANCES: Record< "toast.success": 2, }, "src/components/medications/take-all-due.ts": { "toast.success": 1 }, - "src/components/medications/use-medication-intake.ts": { "toast.success": 4 }, + "src/components/medications/use-medication-intake.ts": { "toast.success": 5 }, "src/components/medications/wizard/medication-wizard-dialog.tsx": { "toast.success": 1, }, diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 87f8e9a70..90f81510a 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -1,5 +1,7 @@ "use client"; +import { LookingAfterCard } from "@/components/dashboard/looking-after-card"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import React, { Suspense, useCallback, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; @@ -241,6 +243,7 @@ export default function DashboardPageClient({ value: number | null | undefined, ): number | null => value == null ? null : unitDisplay.toDisplayDelta(type, value); + const { canAdd } = useRecordCapabilities(); const [quickEntryDialog, setQuickEntryDialog] = useState(null); @@ -901,6 +904,13 @@ export default function DashboardPageClient({ + {/* v1.36.x — the caregiver's front door, directly under the greeting + and above this account's own day. Somebody who opens the app to + check on a parent should not have to go through settings to get + there. Renders nothing for an account nobody has shared with, and + nothing while a switch is already on. */} + + {/* S2 — the Today hero, promoted above the tile strip (MARC sign-off decision 1). Renders the S1 daily digest: the day's read, the honest score/freshness face, and the worth-a-look rail. Shimmer @@ -1976,13 +1986,15 @@ export default function DashboardPageClient({ title={t("dashboard.emptyTitle")} description={t("dashboard.emptyDescription")} action={ - + canAdd ? ( + + ) : undefined } /> ); diff --git a/src/components/dashboard/__tests__/looking-after-card.test.tsx b/src/components/dashboard/__tests__/looking-after-card.test.tsx new file mode 100644 index 000000000..f39f21da7 --- /dev/null +++ b/src/components/dashboard/__tests__/looking-after-card.test.tsx @@ -0,0 +1,139 @@ +/** + * The caregiver's front door, rendered. + * + * SSR-only, like the rest of the component suite: what is proven is the + * paint — who the card lists and when it exists at all. The tap that switches + * records is the switcher's existing mutation and is driven end to end in + * `e2e/account-sharing.spec.ts`. + * + * Mutation checks, run: + * - drop the `accounts.length === 0` bail → "an account nobody shares with + * sees nothing" goes red. + * - drop the `active != null` bail → "no doorway into the room you are in" + * goes red. + * - render the level from `entry.access` instead of `entry.canWrite` → "the + * level is the server's resolved boolean" goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AccountAccess } from "@/lib/sharing/account-access-view"; + +const GRANDMOTHER = { + accountId: "acct-gran", + username: "gran", + displayName: "Margarethe", + access: "read" as const, + canWrite: false, +}; +const FATHER = { + accountId: "acct-dad", + username: "dad", + displayName: null, + access: "write" as const, + canWrite: true, +}; + +const mockAccessRef: { value: AccountAccess } = { + value: { accounts: [], active: null, canSwitch: false }, +}; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "delegate", + username: "delegate", + email: null, + role: "USER", + avatarUrl: null, + modules: {}, + accountAccess: mockAccessRef.value, + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { LookingAfterCard } from "../looking-after-card"; + +function render(access: AccountAccess): string { + mockAccessRef.value = access; + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("an account nobody shares with sees nothing", () => { + // The v1.36.0 posture: the feature is invisible until it applies to you. + // Not an empty state, not a prompt to ask somebody for access. + expect(render({ accounts: [], active: null, canSwitch: false })).toBe(""); + }); + + it("lists every record shared with this account", () => { + const html = render({ + accounts: [GRANDMOTHER, FATHER], + active: null, + canSwitch: true, + }); + expect(html).toContain('data-slot="looking-after-card"'); + expect(html).toContain('data-account-id="acct-gran"'); + expect(html).toContain('data-account-id="acct-dad"'); + // The display name where there is one, the username where there is not. + expect(html).toContain("Margarethe"); + expect(html).toContain("dad"); + }); + + it("the level is the server's resolved boolean", () => { + const html = render({ + accounts: [GRANDMOTHER, FATHER], + active: null, + canSwitch: true, + }); + expect(html).toContain("Can view and add"); + expect(html).toContain("Can view"); + }); + + it("a grant labelled write but resolved read reads as read", () => { + // `access` describes the row; `canWrite` is the decision the server made + // about it this second. A grant that lapsed, or a level this build does + // not honour, must not promise an add the next request would refuse. + const html = render({ + accounts: [{ ...FATHER, access: "write", canWrite: false }], + active: null, + canSwitch: true, + }); + expect(html).toContain("Can view"); + expect(html).not.toContain("Can view and add"); + }); + + it("shows no health data, only a doorway", () => { + const html = render({ + accounts: [GRANDMOTHER], + active: null, + canSwitch: true, + }); + // A preview would put somebody else's readings on this dashboard, outside + // the banner frame that says which record you are looking at. The card + // carries names and levels and nothing else. + expect(html).not.toMatch(/\d+(\.\d+)?\s*(kg|mmHg|bpm)/); + }); + + it("no doorway into the room you are already in", () => { + expect( + render({ + accounts: [GRANDMOTHER], + active: GRANDMOTHER, + canSwitch: true, + }), + ).toBe(""); + }); +}); diff --git a/src/components/dashboard/looking-after-card.tsx b/src/components/dashboard/looking-after-card.tsx new file mode 100644 index 000000000..8e0fce258 --- /dev/null +++ b/src/components/dashboard/looking-after-card.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { HeartHandshake, Loader2 } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { + accountLabel, + type AccountAccessEntry, +} from "@/lib/sharing/account-access-view"; + +/** + * v1.36.x — the caregiver's front door. + * + * ## Why this is on the dashboard and not in settings + * + * Sharing shipped as a settings panel and a menu three levels deep: user menu + * → switcher → the record. That is the right place to MANAGE a grant, and the + * wrong place to use one. Somebody who opens this app every morning to check + * on a parent does not go to settings to do it, and UI-STANDARDS §10 draws + * exactly that line: settings are account utilities, not feature + * destinations. Looking after somebody is a feature destination. + * + * ## What it is not + * + * It is a doorway, not a preview. No numbers, no last reading, no status. + * Putting a slice of somebody else's health record on a delegate's own + * dashboard would place it outside the one frame the whole design leans on — + * the banner that says which record you are inside. Everything about the + * other person is read after the switch, under that banner, or not at all. + * + * ## Where the content comes from + * + * `accountAccess.accounts`, the same resolved list the switcher renders, and + * the switch is the same mutation the switcher fires. No second mechanism, no + * second source of truth about who may open what. An account nobody has + * shared with sees nothing at all — no empty state, no invitation to ask + * somebody for access (the v1.36.0 posture: the feature is invisible until it + * applies to you). + * + * Design: `Card` + `TileHeader` per UI-STANDARDS §1 and §5 (icon and title in + * the foreground colour, title `text-base`), the person's name as content in + * `text-foreground` and the grant level as meta in `text-muted-foreground` + * per §3, and the §11 row shape — label block `min-w-0 flex-1`, the whole row + * a single tap target at the `min-h-11` mobile floor. + */ +export function LookingAfterCard() { + const { t } = useTranslations(); + const { user } = useAuth(); + const switchAccount = useAccountSwitch(); + + const access = user?.accountAccess; + const accounts = access?.accounts ?? []; + + // Inside somebody else's record the card would be a doorway to the room you + // are already in; the banner owns that context and carries the way out. + if (access?.active != null) return null; + if (accounts.length === 0) return null; + + return ( + + + +

+ {t("recordSharing.lookingAfter.description")} +

+
    + {accounts.map((entry) => ( +
  • + +
  • + ))} +
+
+
+ ); +} + +/** + * The grant level, in the words the person reading it would use. + * + * Bound from the server's resolved `canWrite`, never from the `access` label + * beside it: the label describes the row, the boolean is the decision, and a + * lapsed WRITE grant must read as view-only here exactly as it behaves. + * + * No "last opened" line: `lastUsedAt` is not on this payload, and a relative + * time invented from something else would be a placeholder wearing a fact's + * clothes. + */ +function levelLabel( + entry: AccountAccessEntry, + t: (key: string) => string, +): string { + return entry.canWrite + ? t("recordSharing.lookingAfter.levelWrite") + : t("recordSharing.lookingAfter.levelRead"); +} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 93ddc4ee7..476c5da8a 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -95,6 +96,7 @@ export function LabForm({ footerSlot, }: LabFormProps) { const { t } = useTranslations(); + const recordName = useActiveRecordName(); const queryClient = useQueryClient(); const formId = useId(); @@ -199,7 +201,16 @@ export function LabForm({ takenAt: new Date(takenAt).toISOString(), ...(note.trim() ? { note: note.trim() } : {}), }); - toast.success(t("labs.form.savedToast")); + toast.success( + t("labs.form.savedToast"), + recordName + ? { + description: t("recordSharing.toast.savedTo", { + name: recordName, + }), + } + : undefined, + ); if (keepOpen) { // A real lab report shares one blood-draw date across every // analyte — clear the reading but deliberately KEEP `takenAt` so diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 631044d53..11e814478 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useId, useState } from "react"; import { createPortal } from "react-dom"; import { useQueryClient } from "@tanstack/react-query"; @@ -238,6 +239,7 @@ export function MeasurementForm({ footerSlot, }: MeasurementFormProps) { const { t } = useTranslations(); + const recordName = useActiveRecordName(); const queryClient = useQueryClient(); const unitDisplay = useUnitDisplay(); @@ -371,7 +373,19 @@ export function MeasurementForm({ setNotes(""); await invalidateKeys(queryClient, measurementDependentKeys); await refetchInactiveDailyReads(queryClient); - toast.success(t("common.saved")); + // v1.36.x — a delegate's receipt names the record. "Saved" alone is + // the one confirmation somebody acting for another person does not + // need, and the reading has just left their own history for good. + toast.success( + t("common.saved"), + recordName + ? { + description: t("recordSharing.toast.savedTo", { + name: recordName, + }), + } + : undefined, + ); onSuccess?.(); } catch (err) { setError(localizedApiError(err, t, "measurements.saveError")); diff --git a/src/lib/record-activity/__tests__/activity-verb.test.ts b/src/lib/record-activity/__tests__/activity-verb.test.ts new file mode 100644 index 000000000..af4bb9183 --- /dev/null +++ b/src/lib/record-activity/__tests__/activity-verb.test.ts @@ -0,0 +1,77 @@ +/** + * The owner's ledger says what was done, not that something was. + * + * Mutation checks, run: + * - drop the `medications.intake.update` case → "each admitted verb has its + * own line" goes red on that action. + * - make the default arm throw instead of falling back → "an action this + * release has never heard of still renders" goes red. + */ +import { describe, expect, it } from "vitest"; + +import enMessages from "../../../../messages/en.json"; +import { recordActivityVerbLine } from "../activity-verb"; + +/** Resolve a dotted key against the real EN bundle, params interpolated. */ +function translate( + key: string, + params?: Record, +): string { + const value = key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[part] + : undefined, + enMessages, + ); + if (typeof value !== "string") throw new Error(`missing key: ${key}`); + return Object.entries(params ?? {}).reduce( + (text, [k, v]) => text.replaceAll(`{${k}}`, String(v)), + value, + ); +} + +/** + * Every action the eleven admitted write verbs can file, with the audit + * strings the handlers actually pass to `auditLog()`. + */ +const ADMITTED_ACTIONS = [ + "measurement.create", + "labResult.create", + "allergy.create", + "family-history.create", + "illness.episode.create", + "biomarker.create", + "customMetricEntry.create", + "medication.sideEffect.create", + "medication.create", + "medications.intake.update", +] as const; + +describe("recordActivityVerbLine", () => { + it("each admitted verb has its own line", () => { + const generic = translate("recordSharing.activity.acted", { name: "Anna" }); + const lines = ADMITTED_ACTIONS.map((action) => + recordActivityVerbLine(translate, action, "Anna"), + ); + for (const line of lines) { + // The point of the map: none of them may fall through to "made a + // change", which is the line the owner already had. + expect(line).not.toBe(generic); + expect(line).toContain("Anna"); + } + // And no two verbs share a line — a map that answered "added something" + // ten times would pass the check above and say nothing. + expect(new Set(lines).size).toBe(ADMITTED_ACTIONS.length); + }); + + it("an action this release has never heard of still renders", () => { + // Audit rows outlive this map: a later release files verbs it does not + // know, and older rows sit in the same table. Vague beats blank. + expect( + recordActivityVerbLine(translate, "future.thing.create", "Anna"), + ).toBe(translate("recordSharing.activity.acted", { name: "Anna" })); + }); +}); diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts new file mode 100644 index 000000000..9c625730b --- /dev/null +++ b/src/lib/record-activity/activity-verb.ts @@ -0,0 +1,62 @@ +/** The `t` from `useTranslations()`, injected so this stays pure. */ +type Translator = ( + key: string, + params?: Record, +) => string; + +/** + * What somebody did in your record, in a sentence. + * + * ## Why this exists + * + * The activity view renders every non-read row as one generic line: "somebody + * made a change in your record". That was honest while sharing was read-only + * and no delegate could make one. Now a delegate can enter a reading, a lab + * result, an allergy, a family-history entry, an illness entry, a biomarker, + * a tracked value, a side effect, a medication, and can mark a dose taken — + * and "made a change" for ten different things is a way of answering nothing. + * An owner reading this at breakfast wants the verb. + * + * ## Why unknown actions fall back rather than fail + * + * The action string comes from the audit row, which outlives this map: a + * release that admits a twelfth verb writes rows this map has never heard of, + * and rows written by an older image sit in the same table. So an unmapped + * action renders the generic line it renders today. A blank row would be the + * one outcome worse than a vague one. + * + * Pure and translator-injected so the mapping can be pinned without rendering + * a card, and so the keys stay literal for the i18n call-site guard. + */ +export function recordActivityVerbLine( + t: Translator, + action: string, + name: string, +): string { + switch (action) { + case "measurement.create": + return t("recordSharing.activityVerb.measurementCreate", { name }); + case "labResult.create": + return t("recordSharing.activityVerb.labResultCreate", { name }); + case "allergy.create": + return t("recordSharing.activityVerb.allergyCreate", { name }); + case "family-history.create": + return t("recordSharing.activityVerb.familyHistoryCreate", { name }); + case "illness.episode.create": + return t("recordSharing.activityVerb.illnessEpisodeCreate", { name }); + case "biomarker.create": + return t("recordSharing.activityVerb.biomarkerCreate", { name }); + case "customMetricEntry.create": + return t("recordSharing.activityVerb.customMetricEntryCreate", { name }); + case "medication.sideEffect.create": + return t("recordSharing.activityVerb.medicationSideEffectCreate", { + name, + }); + case "medication.create": + return t("recordSharing.activityVerb.medicationCreate", { name }); + case "medications.intake.update": + return t("recordSharing.activityVerb.medicationIntake", { name }); + default: + return t("recordSharing.activity.acted", { name }); + } +}