feat(dashboard): reach shared records from the account switcher, not a card

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 19:24:46 +02:00
1 parent 5445bfc1f1
commit ce9b2814e6
11 files changed
+116 -434

No files matched your search

+1 -6
View File
@@ -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",
+1 -6
View File
@@ -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",
+1 -6
View File
@@ -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",
+1 -6
View File
@@ -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",
+1 -6
View File
@@ -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",
+1 -6
View File
@@ -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",
+83
View File
@@ -0,0 +1,83 @@
/**
* One-shot helper for a text sweep across the six locale bundles.
*
* node scripts/i18n-key-tool.mjs drop <dotted.key> [...]
* node scripts/i18n-key-tool.mjs set <locale> <dotted.key> <value>
* node scripts/i18n-key-tool.mjs show <dotted.key>
*
* 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);
}
@@ -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,
);
+4 -7
View File
@@ -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({
<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 />
{/* 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
@@ -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(
<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", 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&#x27;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("");
});
});
@@ -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 (
<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) => {
const presentation = resolveRecordPresentation(entry);
const level = levelLabel(entry, presentation, t);
const kind = recordKindLabel(entry, t);
const scope = scopeLabel(entry, t);
return (
<li key={entry.accountId}>
<button
type="button"
data-slot="looking-after-row"
data-account-id={entry.accountId}
data-access-level={presentation.access}
data-record-kind={presentation.recordKind}
disabled={switchAccount.isPending}
onClick={() =>
switchAccount.switchTo(
entry.accountId,
sharedRecordLandingHref(entry.sections),
)
}
aria-label={[
t("recordSharing.lookingAfter.open", {
name: accountLabel(entry),
}),
level,
kind,
scope,
].join("; ")}
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">
{level}
</span>
<span className="text-muted-foreground block truncate text-xs">
{kind}
</span>
<span className="text-muted-foreground block truncate text-xs">
{scope}
</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,
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(", ");
}