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:
Marc-André Bombeck committed 2026-08-03 12:12:08 +02:00
1 parent ab53d42874
commit d5a9360c52
14 files changed
+604 -10

No files matched your search

@@ -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");
}
+12 -1
View File
@@ -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"));