mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Give the caregiver a front door and a receipt
Sharing lived in a settings panel and a menu three levels deep. That is where a grant is managed and not where it is used: somebody who opens this app every morning to check on a parent does not go through settings to do it, and the design standards draw the same line, settings being account utilities rather than feature destinations. So the dashboard carries one compact card listing the records shared with this account, each row a one-tap switch through the existing flow. It is a doorway and not a preview. No numbers, no last reading. A slice of somebody else's record on the delegate's own dashboard would sit outside the one frame the design leans on, the banner naming the record you are inside. An account nobody has shared with sees nothing at all. The other half is the receipt: a reading, a lab result or a dose recorded under a switch now says which record it landed in, because "Saved" alone is the confirmation somebody acting for another person does not need. The undo on the dose toast goes at the same time, since a delegate cannot remove what they recorded and an undo they can see is one the server refuses. Also here: the lines the owner's activity view owes each admitted verb, as a translated mapping with a fallback, so a later release filing a verb it has never heard of still renders a sentence rather than a blank row.
This commit is contained in:
1 parent
ab53d42874
commit
d5a9360c52
14 files changed
+604
-10
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+19
-7
@@ -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<QuickEntryDialog>(null);
|
||||
|
||||
@@ -901,6 +904,13 @@ export default function DashboardPageClient({
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
|
||||
|
||||
{/* 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. */}
|
||||
<LookingAfterCard />
|
||||
|
||||
{/* 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={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.emptyAddMeasurement")}
|
||||
</Button>
|
||||
canAdd ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.emptyAddMeasurement")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LookingAfterCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LookingAfterCard>", () => {
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Card data-slot="looking-after-card">
|
||||
<CardContent className="space-y-3">
|
||||
<TileHeader
|
||||
icon={HeartHandshake}
|
||||
title={t("recordSharing.lookingAfter.title")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("recordSharing.lookingAfter.description")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{accounts.map((entry) => (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(entry.accountId)}
|
||||
aria-label={t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
})}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{levelLabel(entry, t)}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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, string | number>,
|
||||
): string {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[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" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/** The `t` from `useTranslations()`, injected so this stays pure. */
|
||||
type Translator = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => 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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user