From ce9b2814e62fbe42186e96fbb3bebb6952e4378a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 19:24:46 +0200 Subject: [PATCH] feat(dashboard): reach shared records from the account switcher, not a card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "looking after" block sat directly under the greeting and above this account's own day, permanently, for anyone with a single shared record. The dashboard is this account's day; other people's records are a destination, and the account switcher in the top bar and the sidebar already is that door — with the same access level and record kind on each entry. Drop the card, its test, and the five strings only it used, across all six locales. The access-payload consumer guard named that one component as the reader of `level` / `sections` / `recordKind`, so it went red on the deletion, which is what it is for. It now names the surfaces that actually adopt those fields — the presentation resolver and the switcher — and matches the property access rather than one variable name, so a rename of the binding cannot make it match nothing and pass. Verified by removing every reader: it fails. Adds `scripts/i18n-key-tool.mjs` for dropping or rewriting a key across all six bundles in one go. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- scripts/i18n-key-tool.mjs | 83 ++++++++ .../account-payload-consumer-guard.test.ts | 33 ++- src/app/page-client.tsx | 11 +- .../__tests__/looking-after-card.test.tsx | 189 ----------------- .../dashboard/looking-after-card.tsx | 192 ------------------ 11 files changed, 116 insertions(+), 434 deletions(-) create mode 100644 scripts/i18n-key-tool.mjs delete mode 100644 src/components/dashboard/__tests__/looking-after-card.test.tsx delete mode 100644 src/components/dashboard/looking-after-card.tsx diff --git a/messages/de.json b/messages/de.json index 722d99e3b..655c12676 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9637,13 +9637,8 @@ "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", "kindShared": "Geteilte Akte", - "kindManaged": "Verwaltetes Profil", - "open": "Akte von {name} öffnen" + "kindManaged": "Verwaltetes Profil" }, "managed": { "title": "Profile, die du betreust", diff --git a/messages/en.json b/messages/en.json index 7afbc0765..937dd96d6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9637,13 +9637,8 @@ "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", "kindShared": "Shared record", - "kindManaged": "Managed profile", - "open": "Open {name}'s record" + "kindManaged": "Managed profile" }, "managed": { "title": "Records you look after", diff --git a/messages/es.json b/messages/es.json index 8f6a2f6b1..880809f8d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9637,13 +9637,8 @@ "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", "kindShared": "Registro compartido", - "kindManaged": "Perfil administrado", - "open": "Abrir el historial de {name}" + "kindManaged": "Perfil administrado" }, "managed": { "title": "Perfiles de los que te ocupas", diff --git a/messages/fr.json b/messages/fr.json index f26b406aa..45dfdd626 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9637,13 +9637,8 @@ "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", "kindShared": "Dossier partagé", - "kindManaged": "Profil géré", - "open": "Ouvrir le dossier de {name}" + "kindManaged": "Profil géré" }, "managed": { "title": "Profils dont vous vous occupez", diff --git a/messages/it.json b/messages/it.json index 7175278a8..fdb54961b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9637,13 +9637,8 @@ "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", "kindShared": "Cartella condivisa", - "kindManaged": "Profilo gestito", - "open": "Apri la cartella di {name}" + "kindManaged": "Profilo gestito" }, "managed": { "title": "Profili di cui ti occupi", diff --git a/messages/pl.json b/messages/pl.json index 972357d82..af9ee6bc6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9637,13 +9637,8 @@ "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ć", "kindShared": "Udostępniona karta", - "kindManaged": "Zarządzany profil", - "open": "Otwórz kartę użytkownika {name}" + "kindManaged": "Zarządzany profil" }, "managed": { "title": "Profile, którymi się opiekujesz", diff --git a/scripts/i18n-key-tool.mjs b/scripts/i18n-key-tool.mjs new file mode 100644 index 000000000..f928ceecb --- /dev/null +++ b/scripts/i18n-key-tool.mjs @@ -0,0 +1,83 @@ +/** + * One-shot helper for a text sweep across the six locale bundles. + * + * node scripts/i18n-key-tool.mjs drop [...] + * node scripts/i18n-key-tool.mjs set + * node scripts/i18n-key-tool.mjs show + * + * Keys are dotted paths into the bundle; a segment containing a literal dot + * is not supported (no such key exists). `drop` removes the key from every + * locale and prunes an object left empty behind it. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +const LOCALES = ["de", "en", "es", "fr", "it", "pl"]; +const file = (loc) => `messages/${loc}.json`; + +function read(loc) { + return JSON.parse(readFileSync(file(loc), "utf8")); +} +function write(loc, data) { + writeFileSync(file(loc), `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} + +function drop(bundle, path) { + const parts = path.split("."); + const last = parts.pop(); + const chain = []; + let node = bundle; + for (const part of parts) { + if (node == null || typeof node !== "object") return false; + chain.push([node, part]); + node = node[part]; + } + if (node == null || !(last in node)) return false; + delete node[last]; + for (let i = chain.length - 1; i >= 0; i -= 1) { + const [parent, key] = chain[i]; + if (parent[key] && Object.keys(parent[key]).length === 0) + delete parent[key]; + else break; + } + return true; +} + +function set(bundle, path, value) { + const parts = path.split("."); + const last = parts.pop(); + let node = bundle; + for (const part of parts) { + if (typeof node[part] !== "object" || node[part] === null) node[part] = {}; + node = node[part]; + } + node[last] = value; +} + +function get(bundle, path) { + return path.split(".").reduce((n, p) => (n == null ? n : n[p]), bundle); +} + +const [cmd, ...rest] = process.argv.slice(2); + +if (cmd === "drop") { + for (const loc of LOCALES) { + const bundle = read(loc); + let n = 0; + for (const key of rest) if (drop(bundle, key)) n += 1; + write(loc, bundle); + console.log(`${loc}: dropped ${n}/${rest.length}`); + } +} else if (cmd === "set") { + const [loc, key, ...valueParts] = rest; + const bundle = read(loc); + set(bundle, key, valueParts.join(" ")); + write(loc, bundle); + console.log(`${loc}: ${key} set`); +} else if (cmd === "show") { + for (const loc of LOCALES) { + console.log(`${loc}\t${JSON.stringify(get(read(loc), rest[0]))}`); + } +} else { + console.error("usage: drop|set|show"); + process.exit(1); +} diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 13d7f7e98..4361eb4c5 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -80,12 +80,17 @@ const NOT_A_CONSUMER = new Set([ join(SRC, "app", "api", "auth", "me", "route.ts"), ]); -const ACCOUNT_ENTRY_CONSUMER = join( - SRC, - "components", - "dashboard", - "looking-after-card.tsx", -); +/** + * The surfaces that read a per-account access ENTRY. This was one file (the + * dashboard's caring-for card) until that card came off the dashboard — + * records other people share are reached from the account switcher instead. + * A single named file was the fragile part: the guarantee is "every additive + * field has a reader", not "this one component still exists". + */ +const ACCOUNT_ENTRY_CONSUMERS = [ + join(SRC, "lib", "navigation", "record-presentation.ts"), + join(SRC, "components", "layout", "account-switcher-menu.tsx"), +]; const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts"); const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [ "level", @@ -231,8 +236,8 @@ describe("account payload consumer guard", () => { }); it("has non-zero entry and active-record readers for additive access fields", () => { - const entrySource = stripComments( - readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"), + const entrySources = ACCOUNT_ENTRY_CONSUMERS.map((file) => + stripComments(readFileSync(file, "utf8")), ); const activeSource = stripComments( readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"), @@ -240,9 +245,17 @@ describe("account payload consumer guard", () => { const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce( (count, field) => { - expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`)); + // The reader binds the entry under its own name (`entry`, `account`, + // …), so the matcher pins the property access, not the variable. An + // empty match set fails rather than passing quietly. + const read = new RegExp(`\\w\\.${field}(?![\\w$])`); + const readers = entrySources.filter((source) => read.test(source)); + expect( + readers.length, + `no client reader for account access field \`${field}\``, + ).toBeGreaterThan(0); expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`)); - return count + 2; + return count + readers.length + 1; }, 0, ); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 0174dd042..f5fa53d10 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -1,6 +1,5 @@ "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"; @@ -931,12 +930,10 @@ 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. */} - + {/* Records other people share with you are reached from the account + switcher in the top bar and the sidebar, not from a card here. The + dashboard is this account's own day; a permanent block about other + people's records sat above it and pushed it down. */} {/* S2 — the Today hero, promoted above the tile strip (MARC sign-off decision 1). Renders the S1 daily digest: the day's read, the diff --git a/src/components/dashboard/__tests__/looking-after-card.test.tsx b/src/components/dashboard/__tests__/looking-after-card.test.tsx deleted file mode 100644 index d67408a89..000000000 --- a/src/components/dashboard/__tests__/looking-after-card.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * 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, - level: "read" as const, - sections: null, - recordKind: "shared" as const, - canWrite: false, -}; -const FATHER = { - accountId: "acct-dad", - username: "dad", - displayName: null, - access: "write" as const, - level: "write" as const, - sections: null, - recordKind: "shared" as const, - canWrite: true, -}; -const MANAGED_PROFILE = { - accountId: "managed-profile", - username: "managed-profile", - displayName: "Managed profile", - access: "write" as const, - level: "manage" as const, - sections: null, - recordKind: "managed" as const, - canWrite: true, -}; - -const mockAccessRef: { value: AccountAccess } = { - value: { accounts: [], active: null, canSwitch: false }, -}; - -const mockAccount = () => ({ - id: "delegate", - username: "delegate", - email: null, - role: "USER", - avatarUrl: null, - modules: {}, - accountAccess: mockAccessRef.value, -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: mockAccount(), - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), - // The card reads the mount-pinned accessor: it paints inside the dashboard's - // streamed boundary, where reading the grants on the hydration render would - // disagree with the server and cost the whole streamed tree. These cases are - // about the MOUNTED render, so the accessor answers the account. - useAccountOnceMounted: () => mockAccount(), -})); - -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", level: "write", canWrite: false }, - ], - active: null, - canSwitch: true, - }); - expect(html).toContain("Can view"); - expect(html).not.toContain("Can view and add"); - }); - - it("keeps MANAGE and managed profile labels separate", () => { - const html = render({ - accounts: [MANAGED_PROFILE], - active: null, - canSwitch: true, - }); - - expect(html).toContain('data-access-level="manage"'); - expect(html).toContain('data-record-kind="managed"'); - expect(html).toContain("Can read the record, add to it, and change"); - expect(html).toContain("Managed profile"); - }); - - it("names the record button with its action, access, and kind", () => { - const html = render({ - accounts: [MANAGED_PROFILE], - active: null, - canSwitch: true, - }); - - expect(html).toMatch( - /aria-label="Open Managed profile's record; Can read the record, add to it, and change or remove what is in it; Managed profile;/, - ); - }); - - 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 deleted file mode 100644 index cccb7f4d4..000000000 --- a/src/components/dashboard/looking-after-card.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"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 { useAccountOnceMounted } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { - resolveRecordPresentation, - type RecordPresentation, -} from "@/lib/navigation/record-presentation"; -import { sharedRecordLandingHref } from "@/lib/navigation/shared-record"; -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(); - // Withheld until mounted: this card sits inside the dashboard's streamed - // boundary and renders nothing without grants. The shell has usually answered - // `/api/auth/me` before the boundary hydrates, so reading the grants on the - // hydration render paints a card where the server painted none — a mismatch - // that discards the streamed dashboard. See `useAccountOnceMounted`. - const user = useAccountOnceMounted(); - 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) => { - const presentation = resolveRecordPresentation(entry); - const level = levelLabel(entry, presentation, t); - const kind = recordKindLabel(entry, t); - const scope = scopeLabel(entry, t); - - return ( -
  • - -
  • - ); - })} -
-
-
- ); -} - -/** - * 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, - presentation: RecordPresentation, - t: (key: string) => string, -): string { - if (entry.level === "manage") { - return t("recordSharing.row.canManage"); - } - return presentation.access === "view-and-add" - ? t("recordSharing.lookingAfter.levelWrite") - : t("recordSharing.lookingAfter.levelRead"); -} - -function recordKindLabel( - entry: AccountAccessEntry, - t: (key: string) => string, -): string { - return entry.recordKind === "managed" - ? t("recordSharing.lookingAfter.kindManaged") - : t("recordSharing.lookingAfter.kindShared"); -} - -function scopeLabel( - entry: AccountAccessEntry, - t: (key: string) => string, -): string { - if (entry.sections === null) { - return t("recordSharing.row.entireRecord"); - } - if (entry.sections.length === 0) { - return t("recordSharing.row.noSections"); - } - return entry.sections - .map((domain) => t(`recordSharing.section.${domain}.label`)) - .join(", "); -}