From 3e7127dca5baae76525a9c94123d13fb680295a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 3 Aug 2026 12:11:18 +0200
Subject: [PATCH 1/7] Ask what the record on screen allows before rendering a
control
Account sharing shipped read-only, and everything that consulted the grant
lived in the application chrome: the nav bars, the banner, the switcher. No
feature surface asked. A delegate opened somebody else's record, saw the add
button on every page, filled the form, and was refused by a server that was
right to refuse. Nothing leaked; it taught people the product was broken.
This is the answer every mutation affordance asks for. Two booleans, because
a grant at WRITE admits a short closed list of verbs and nothing else:
`canAdd` for an entry of an admitted kind, `canManage` for changing what is
already there or adding what the list does not name. `canWrite` arrives
resolved from the server and is bound, never recomputed; what this decides is
only which class of control a resolved level covers.
The absent case is the permissive one. Failing closed would blank every add
button in the app for every account on every cold load, to spare a delegate
one frame between the reload and the account query landing.
---
.../__tests__/use-record-capabilities.test.ts | 85 ++++++++++++
src/hooks/use-record-capabilities.ts | 124 ++++++++++++++++++
2 files changed, 209 insertions(+)
create mode 100644 src/hooks/__tests__/use-record-capabilities.test.ts
create mode 100644 src/hooks/use-record-capabilities.ts
diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts
new file mode 100644
index 000000000..c7ef43c52
--- /dev/null
+++ b/src/hooks/__tests__/use-record-capabilities.test.ts
@@ -0,0 +1,85 @@
+/**
+ * What a delegation lets somebody do, pinned as a table.
+ *
+ * The hook itself is one line over `useAuth`; the decision lives in
+ * `resolveRecordCapabilities`, which is pure and has exactly three inputs a
+ * session can be in. So the contract is enumerated here rather than inferred
+ * from a rendered page — and every affordance in the sweep binds one of these
+ * two booleans, which is what makes the sweep provable at all.
+ *
+ * Mutation checks, run:
+ * - return `canManage: true` for a WRITE record → "a delegate may not change
+ * what is already there" goes red.
+ * - return `canAdd: true` for a READ record → "a read-only delegate adds
+ * nothing" goes red.
+ * - make the `!active` branch return `canAdd: false` → "the caller's own
+ * record keeps everything" goes red.
+ */
+import { describe, expect, it } from "vitest";
+
+import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
+import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
+
+const READ_ONLY: AccountAccessEntry = {
+ accountId: "acct-owner",
+ username: "owner",
+ displayName: "Margarethe",
+ access: "read",
+ canWrite: false,
+};
+
+const WRITABLE: AccountAccessEntry = {
+ ...READ_ONLY,
+ access: "write",
+ canWrite: true,
+};
+
+describe("resolveRecordCapabilities", () => {
+ it("the caller's own record keeps everything", () => {
+ // `null` is not "no access", it is "this is mine". Both an unswitched
+ // session and a payload from a server that has never heard of sharing
+ // land here, which is why the absent case must be the permissive one.
+ expect(resolveRecordCapabilities(null)).toEqual({
+ inSharedRecord: false,
+ canWrite: false,
+ canAdd: true,
+ canManage: true,
+ });
+ expect(resolveRecordCapabilities(undefined)).toEqual(
+ resolveRecordCapabilities(null),
+ );
+ });
+
+ it("a read-only delegate adds nothing", () => {
+ expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
+ inSharedRecord: true,
+ canWrite: false,
+ canAdd: false,
+ canManage: false,
+ });
+ });
+
+ it("a delegate may not change what is already there", () => {
+ // The asymmetry the whole design rests on: a WRITE grant adds, and that
+ // is all it does. Not even to an entry the delegate made a minute ago.
+ expect(resolveRecordCapabilities(WRITABLE)).toEqual({
+ inSharedRecord: true,
+ canWrite: true,
+ canAdd: true,
+ canManage: false,
+ });
+ });
+
+ it("binds the server's boolean rather than the grant's label", () => {
+ // `access` is descriptive; `canWrite` is the resolved decision. A row that
+ // says "write" while the server resolved false (lapsed, revoked mid-flight,
+ // a level this build does not honour) must read as read-only, because two
+ // programs deciding one person's access is how they end up disagreeing.
+ const disagreeing: AccountAccessEntry = {
+ ...READ_ONLY,
+ access: "write",
+ canWrite: false,
+ };
+ expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
+ });
+});
diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts
new file mode 100644
index 000000000..b57186c6d
--- /dev/null
+++ b/src/hooks/use-record-capabilities.ts
@@ -0,0 +1,124 @@
+"use client";
+
+import { useAuth } from "@/hooks/use-auth";
+import {
+ accountLabel,
+ type AccountAccessEntry,
+} from "@/lib/sharing/account-access-view";
+
+/**
+ * What the person at the keyboard may do to the record on screen.
+ *
+ * ## Why this exists
+ *
+ * v1.36.0 shipped account sharing read-only, and everything that consulted
+ * the grant lived in the application chrome: the nav bars, the banner, the
+ * switcher. No feature surface asked. So a delegate opened `/measurements`
+ * inside somebody else's record, saw the add button, filled the form, and was
+ * refused by the server. Nothing leaked, because the refusal was right. It
+ * still taught them the product was broken. This hook is what every mutation
+ * affordance asks before it renders.
+ *
+ * ## The two answers, and why there are two
+ *
+ * A grant at WRITE admits a short, closed list of verbs: entering readings,
+ * lab results, allergies, family history, an illness entry, a biomarker, a
+ * tracked value, a side effect, a medication, and marking a dose taken. It
+ * admits nothing else. Editing, deleting, restoring, purging, importing and
+ * bulk-acting stay with the owner, including on an entry the delegate added
+ * themselves a minute ago, and so does every create the list does not name
+ * (a mood entry, a screener, a cycle day). Two booleans carry that:
+ *
+ * - `canAdd` — may this person add one of the admitted kinds.
+ * - `canManage` — may this person change what is already there, or add
+ * something the delegation does not cover. True only in one's own record.
+ *
+ * A surface that offers an admitted create asks `canAdd`. Everything else
+ * asks `canManage`. Neither is a permission decision made here: `canWrite`
+ * arrives resolved from the server on `accountAccess.active` and is bound,
+ * never recomputed (`account-access-view.ts:11-15`). What this file decides is
+ * only which class of affordance a resolved level covers, and that mapping is
+ * a property of the release, not of the caller.
+ *
+ * ## Absent, not disabled
+ *
+ * The consuming rule, stated once here because it belongs to the whole sweep:
+ * a control this returns `false` for does not render at all. A greyed-out
+ * button still says "this exists and does not work for you"; a control that is
+ * simply not there says "this is not part of what you were given", which is
+ * the truth. The banner already states the mode globally, so no per-control
+ * chip explains the absence — only an empty state a person would otherwise
+ * search a button in gets a sentence.
+ */
+export interface RecordCapabilities {
+ /** Is this browser acting on somebody else's record right now. */
+ inSharedRecord: boolean;
+ /** The grant's resolved level for the record on screen. */
+ canWrite: boolean;
+ /** May the caller add an entry of a kind the delegation admits. */
+ canAdd: boolean;
+ /** May the caller change what exists, or add what the delegation excludes. */
+ canManage: boolean;
+}
+
+/**
+ * The mapping, as a pure function of the record the session is inside.
+ *
+ * Exported so its contract can be pinned without a click: the three states a
+ * session can be in are enumerable, and the test enumerates them. `null` is
+ * one's own record, which is every capability.
+ */
+export function resolveRecordCapabilities(
+ active: AccountAccessEntry | null | undefined,
+): RecordCapabilities {
+ if (!active) {
+ return {
+ inSharedRecord: false,
+ canWrite: false,
+ canAdd: true,
+ canManage: true,
+ };
+ }
+ return {
+ inSharedRecord: true,
+ canWrite: active.canWrite,
+ canAdd: active.canWrite,
+ canManage: false,
+ };
+}
+
+/**
+ * The record on screen, and what may be done to it.
+ *
+ * Reads the same resolved block the banner and the switcher read, so the
+ * chrome and the controls beneath it cannot disagree about what a delegate
+ * was given.
+ *
+ * Before `/api/auth/me` settles, `active` is absent and the answer is the
+ * caller's own record. That is deliberate rather than fail-closed: failing
+ * closed would blank every add button in the app for every account on every
+ * cold load, to spare a delegate one frame. The switch flow ends in a hard
+ * reload, so a delegate's first paint can show an add control for as long as
+ * the account query takes, and then it goes. A control that appears and
+ * withdraws is a much smaller lie than one that stays and 403s, and the
+ * banner lands from the same query in the same frame.
+ */
+export function useRecordCapabilities(): RecordCapabilities {
+ const { user } = useAuth();
+ return resolveRecordCapabilities(user?.accountAccess?.active);
+}
+
+/**
+ * The name of the record this browser is inside, or null in one's own.
+ *
+ * The delegate's receipt: a mutation that lands under a switch says which
+ * record it landed in, because "Saved" alone is the one confirmation a person
+ * acting for somebody else does not need. Kept beside the capabilities because
+ * it reads the same resolved entry and would otherwise be a fourth place that
+ * decides what to call somebody.
+ */
+export function useActiveRecordName(): string | null {
+ const { user } = useAuth();
+ const active = user?.accountAccess?.active ?? null;
+ return active ? accountLabel(active) : null;
+}
From bc43a9f0620944cab33e10e85dde9197924a5388 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 3 Aug 2026 12:11:29 +0200
Subject: [PATCH 2/7] Drop the shared delete, bulk and capture affordances
under a switch
Three controls carry most of the app's mutation surface between them. The row
delete is spelled once for measurements, lab results, biomarkers, allergies,
family history, custom metrics and medication supply; the selection bar is
the only bulk delete; the capture picker is the fastest path to four entry
forms on a phone and bypasses every page header.
Deleting belongs to the owner at every grant level, so the first two are
absent inside somebody else's record rather than greyed out: a disabled bin
still claims the row is the delegate's to remove. The picker keeps the two
kinds a delegation admits and drops a mood entry and a glass of water, which
the server refuses under a switch; when nothing survives, the bar's plus
button goes with them instead of opening an empty sheet.
---
.../delegated-write-affordances.test.tsx | 198 ++++++++++++++++++
src/components/data-list/delete-button.tsx | 12 ++
.../data-list/selection-action-bar.tsx | 9 +-
.../__tests__/capture-picker-discard.test.tsx | 13 ++
.../layout/__tests__/capture-picker.test.tsx | 13 ++
src/components/layout/bottom-nav.tsx | 31 ++-
src/components/layout/capture-picker.tsx | 92 ++++++--
7 files changed, 334 insertions(+), 34 deletions(-)
create mode 100644 src/components/__tests__/delegated-write-affordances.test.tsx
diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx
new file mode 100644
index 000000000..ae1f4e03d
--- /dev/null
+++ b/src/components/__tests__/delegated-write-affordances.test.tsx
@@ -0,0 +1,198 @@
+/**
+ * What a delegate is offered, rendered rather than described.
+ *
+ * v1.36.0 shipped sharing read-only and gated it only in the application
+ * chrome. Every feature surface still painted its add button, and a delegate
+ * who tapped one met a 403 from a server that was right to refuse. These
+ * render the real controls to markup and assert the paint, on the three
+ * capability states a session can be in.
+ *
+ * The suite is SSR-only (`@testing-library/react` is not a dependency here),
+ * which bounds what it can prove: it holds the RENDER, never the click. A
+ * control that is absent here cannot be tapped, and that is the whole property
+ * — but a control that is present here is not proven to work, and a submit
+ * path is not proven at all. That leg lives in `e2e/account-sharing.spec.ts`.
+ *
+ * Mutation checks, run:
+ * - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
+ * read-only legs for the intake row and the card menu go red.
+ * - `DeleteButton` dropping its `canManage` bail → "no row delete inside
+ * somebody else's record" 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 OWNER = {
+ accountId: "acct-owner",
+ username: "owner",
+ displayName: "Margarethe",
+ access: "read" as const,
+ canWrite: false,
+};
+
+const OWN_RECORD: AccountAccess = {
+ accounts: [OWNER],
+ active: null,
+ canSwitch: true,
+};
+const READ_ONLY: AccountAccess = {
+ accounts: [OWNER],
+ active: OWNER,
+ canSwitch: true,
+};
+const WRITABLE: AccountAccess = {
+ accounts: [OWNER],
+ active: { ...OWNER, access: "write", canWrite: true },
+ canSwitch: true,
+};
+
+const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
+
+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(),
+ }),
+}));
+
+import { DeleteButton } from "@/components/data-list/delete-button";
+import { SelectionActionBar } from "@/components/data-list/selection-action-bar";
+import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
+import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
+import { visibleCaptureKinds } from "@/components/layout/capture-picker";
+
+function render(access: AccountAccess, node: React.ReactNode): string {
+ mockAccessRef.value = access;
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("row delete", () => {
+ const node = (
+ {}} title="Delete?" description="Gone." />
+ );
+
+ it("renders in the caller's own record", () => {
+ expect(render(OWN_RECORD, node)).toContain("
- remove.mutate(doc.id)}
- disabled={remove.isPending}
- >
-
- {t("documents.detail.delete")}
-
+ {/* v1.36.x — deleting, sharing and re-filing a document are the
+ owner's alone. Inside somebody else's record the sheet reads
+ the document and offers the download, and the leading slot
+ simply has nothing in it. */}
+ {canManage ? (
+ remove.mutate(doc.id)}
+ disabled={remove.isPending}
+ >
+
+ {t("documents.detail.delete")}
+
+ ) : (
+
+ )}
) : (
{t("documents.detail.kindLabel")}
+ {/* Read-only rather than absent: the value IS the content
+ a delegate came to read, and a form field that shows it
+ without accepting a change is the honest rendering. */}
) : (
diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx
index 79e4a8a17..98c444d23 100644
--- a/src/components/illness/illness-episode-detail.tsx
+++ b/src/components/illness/illness-episode-detail.tsx
@@ -8,6 +8,7 @@
* red-flag / pre-onset / nadir). Calm + retrospective — no prediction UI, no
* alarming colour.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
@@ -36,6 +37,9 @@ function todayLocal(): string {
export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
+ // v1.36.x — logging a day, editing and resolving an episode are the
+ // owner's; a delegate reads the episode.
+ const { canManage } = useRecordCapabilities();
const fmt = useFormatters();
const {
data: episode,
@@ -93,26 +97,28 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
+ )}
);
@@ -308,6 +316,10 @@ function EpisodeGroup({
export function IllnessView() {
const { t } = useTranslations();
+ // v1.36.x — opening an illness entry is an admitted delegated write. Logging
+ // a day against one, editing it, resolving it and deleting it are not, so a
+ // delegate can start the record of an illness and nothing more.
+ const { canAdd, canManage } = useRecordCapabilities();
const {
data: episodes,
isLoading,
@@ -380,29 +392,33 @@ export function IllnessView() {
<>
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
-
-
-
-
-
+
+
+
+
+ )}
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
- setNewOpen(true)}
- className="min-h-11 sm:min-h-9"
- >
-
- {t("common.add")}
-
+ {canAdd && (
+ setNewOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("common.add")}
+
+ )}
>
}
/>
@@ -458,10 +474,12 @@ export function IllnessView() {
description={t("illness.empty.body")}
ctaSize="lg"
action={
- setNewOpen(true)}>
-
- {t("illness.newEpisode")}
-
+ canAdd ? (
+ setNewOpen(true)}>
+
+ {t("illness.newEpisode")}
+
+ ) : undefined
}
/>
)}
diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx
index 6985618a7..d57b42bd7 100644
--- a/src/components/labs/lab-biomarker-detail.tsx
+++ b/src/components/labs/lab-biomarker-detail.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -93,6 +94,7 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
+ const { canAdd, canManage } = useRecordCapabilities();
const { t, locale } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
@@ -346,17 +348,19 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
- setEditOpen(true)}
- disabled={!marker}
- aria-label={t("labs.biomarker.edit")}
- title={t("labs.biomarker.edit")}
- >
-
-
+ {canManage && (
+ setEditOpen(true)}
+ disabled={!marker}
+ aria-label={t("labs.biomarker.edit")}
+ title={t("labs.biomarker.edit")}
+ >
+
+
+ )}
{/* "Show all readings" mirrors the metric sub-pages' ``
control. The full reading feed lives on
`/labs/[biomarkerId]/values`; the detail page keeps the
@@ -378,18 +382,22 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null}
- setAddOpen(true)}
- // v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
- // text "Add" button crowd the row and truncate the title hard.
- // Drop the Add button to icon-only under `sm` (label kept for
- // screen readers via `aria-label`); the full text returns at `sm+`.
- className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
- aria-label={t("labs.addResult")}
- >
-
- {t("labs.addResult")}
-
+ {/* v1.36.x — entering a result is an admitted delegated write;
+ renaming or removing the marker is not. */}
+ {canAdd && (
+ setAddOpen(true)}
+ // v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
+ // text "Add" button crowd the row and truncate the title hard.
+ // Drop the Add button to icon-only under `sm` (label kept for
+ // screen readers via `aria-label`); the full text returns at `sm+`.
+ className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
+ aria-label={t("labs.addResult")}
+ >
+
+ {t("labs.addResult")}
+
+ )}
@@ -429,9 +437,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
- setAddOpen(true)}>
- {t("labs.addResult")}
-
+ canAdd ? (
+ setAddOpen(true)}>
+ {t("labs.addResult")}
+
+ ) : undefined
}
/>
diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx
index 5b547dd7a..329ce1561 100644
--- a/src/components/labs/lab-history-list.tsx
+++ b/src/components/labs/lab-history-list.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
@@ -74,6 +75,7 @@ function parseDecimal(raw: string): number | null {
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
+ const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
// Client-side column sort over the in-memory reading feed.
@@ -361,17 +363,19 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
- void openEdit(r)}
- aria-label={t("labs.editReading")}
- >
-
-
+ {canManage && (
+ void openEdit(r)}
+ aria-label={t("labs.editReading")}
+ >
+
+
+ )}
deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index b96f62136..99573ec0b 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
@@ -77,6 +78,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
+ const { canAdd } = useRecordCapabilities();
const { prefs } = useModuleListPrefs("labs");
// v1.22 — a short, factual line under each marker heading describing what the
@@ -167,7 +169,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
- onAddFirst ? (
+ onAddFirst && canAdd ? (
{t("labs.addFirst")}
) : undefined
}
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 941f66f29..3f0fa6413 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -28,6 +28,7 @@
* hosting the per-reminder enable/disable toggles (moved off the
* card so the card carries a single med-style kebab).
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -219,6 +220,7 @@ export function VorsorgeSection({
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
+ const { canManage } = useRecordCapabilities();
const router = useRouter();
const {
data: reminders,
@@ -359,7 +361,11 @@ export function VorsorgeSection({
// v1.18.6 (MOD-02) — the primary add affordance reads "hinzufügen" like
// every other module's add button.
- const addButton = (
+ // v1.36.x — a checkup reminder belongs to the person whose schedule it is,
+ // and no grant level admits creating, editing or satisfying one. Inside
+ // somebody else's record the schedule stays readable and every control that
+ // would change it is absent.
+ const addButton = canManage ? (
{t("common.add")}
- );
+ ) : null;
// v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view,
// reorder, per-reminder enable toggles), left of the Add button — the
// canonical medication-page pattern. The old kebab "Anpassen" sheet is
// gone (MOD-07); its toggles moved to the settings page.
- const wrenchButton = (
+ const wrenchButton = canManage ? (
- );
+ ) : null;
return (
@@ -709,9 +715,11 @@ export function VorsorgeSection({
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
-
- {t("common.add")}
-
+ canManage ? (
+
+ {t("common.add")}
+
+ ) : undefined
}
/>
)}
@@ -787,6 +795,7 @@ function VorsorgeCard({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
// The next-due phrase reads off the SAME zone as the last-done date below
@@ -855,7 +864,7 @@ function VorsorgeCard({
// Single med-style kebab: Edit + Delete. The Delete item is the shared
// confirm-on-delete control rendered as a full-width menu row.
- const headerActions = (
+ const headerActions = canManage ? (
- );
+ ) : null;
// The measure / mark-done action. One constant appearance in every state —
// no due-driven tint, no label swap (v1.27.5 retired the former green
// "do it now" wash; a button that changes colour by schedule state reads as
// a different control). Due-ness is communicated by the discreet coloured
// next-due text above.
- const primaryButton = (
+ const primaryButton = canManage ? (
)}
- );
+ ) : null;
const deleteDialog = (
@@ -1114,7 +1123,9 @@ function VorsorgeCard({
)}
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
-
{primaryButton}
+ {primaryButton ? (
+
{primaryButton}
+ ) : null}
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx
index 3af1468de..912e9c5d7 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -1,8 +1,10 @@
"use client";
+import { accountLabel } from "@/lib/sharing/account-access-view";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -283,7 +285,20 @@ export function MeasurementList({
}: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
- const { isAuthenticated } = useAuth();
+ const { isAuthenticated, user } = useAuth();
+ // v1.36.x — inside somebody else's record, only the add path survives:
+ // entering a reading is an admitted delegated write, editing and deleting
+ // one are not. The row controls are dropped rather than disabled.
+ const { canAdd, canManage, inSharedRecord, canWrite } =
+ useRecordCapabilities();
+ const activeRecord = user?.accountAccess?.active ?? null;
+ // Named only when the sentence below is owed: a delegate who may add but
+ // not change. In the caller's own record, and for a read-only delegate who
+ // was offered no add button to be surprised by, there is nothing to explain.
+ const delegateName =
+ inSharedRecord && canWrite && activeRecord
+ ? accountLabel(activeRecord)
+ : null;
const unitDisplay = useUnitDisplay();
// v1.32.26 — a single-reading row renders its value + unit in the user's
// preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the
@@ -928,38 +943,58 @@ export function MeasurementList({
// an outage never reads as "you have no measurements".
void refetch()} />
) : !data?.measurements?.length ? (
- // v1.4.15 phase-C5: replaces the bare-text empty rectangle.
- // Filter-aware copy distinguishes brand-new accounts ("no
- // measurements yet") from filtered-out lists ("no measurements
- // match this filter") and exposes the right CTA for each
- // case.
- }
- title={
- typeFilter === "ALL" || lockedType
- ? t("measurements.emptyTitle")
- : t("measurements.emptyFilteredTitle")
- }
- description={
- typeFilter === "ALL" || lockedType
- ? t("measurements.emptyDescription")
- : t("measurements.emptyFilteredDescription")
- }
- action={
- // v1.8.5 — a locked-type list has no "reset filter" path;
- // the metric is fixed by the route.
- typeFilter !== "ALL" && !lockedType ? (
- setTypeFilter("ALL")}>
- {t("measurements.emptyResetFilter")}
-
- ) : onAddFirst ? (
-
-
- {t("measurements.emptyAddFirst")}
-
- ) : undefined
- }
- />
+ <>
+ {/* v1.4.15 phase-C5: replaces the bare-text empty rectangle.
+ Filter-aware copy distinguishes brand-new accounts ("no
+ measurements yet") from filtered-out lists ("no measurements
+ match this filter") and exposes the right CTA for each case. */}
+ }
+ title={
+ typeFilter === "ALL" || lockedType
+ ? t("measurements.emptyTitle")
+ : t("measurements.emptyFilteredTitle")
+ }
+ description={
+ typeFilter === "ALL" || lockedType
+ ? t("measurements.emptyDescription")
+ : t("measurements.emptyFilteredDescription")
+ }
+ action={
+ // v1.8.5 — a locked-type list has no "reset filter" path;
+ // the metric is fixed by the route.
+ typeFilter !== "ALL" && !lockedType ? (
+ setTypeFilter("ALL")}
+ >
+ {t("measurements.emptyResetFilter")}
+
+ ) : onAddFirst && canAdd ? (
+
+
+ {t("measurements.emptyAddFirst")}
+
+ ) : undefined
+ }
+ />
+ {/* The one place in the app the asymmetry is spelled out. A
+ delegate who may add will look for the edit control beside the
+ row they just entered, so the list that carries the add button
+ says once, in one muted line, where the limit is. Every other
+ hidden control stays silent: the banner already names the mode,
+ and a chip beside each absent button would be noise. */}
+ {delegateName && (
+
+ )}
+ >
) : (
<>
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
@@ -978,19 +1013,23 @@ export function MeasurementList({
- {/* v1.15.13 — select-all-on-page header checkbox. */}
-
+ {/* v1.15.13 — select-all-on-page header checkbox.
+ v1.36.x — bulk delete is the owner's alone, so a
+ delegate gets no selection column. */}
+ {canManage && (
+
+ )}
{t("measurements.type")}
@@ -1051,7 +1090,7 @@ export function MeasurementList({
{/* Grouped/synthetic rows aren't individually
deletable, so they have no checkbox. */}
- {!isGrouped && (
+ {!isGrouped && canManage && (
onToggleRow(m.id)}
@@ -1178,15 +1217,17 @@ export function MeasurementList({
) : (
<>
- startEdit(m)}
- aria-label={t("common.edit")}
- >
-
-
+ {canManage && (
+ startEdit(m)}
+ aria-label={t("common.edit")}
+ >
+
+
+ )}
deleteMutation.mutate(m.id)
@@ -1247,7 +1288,7 @@ export function MeasurementList({
{/* v1.15.13 — multi-select checkbox in a 44px tap
target; absent for synthetic grouped rows. */}
- {!isGrouped && (
+ {!isGrouped && canManage && (
// v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
// (itself a ``) inside a
// 44px wrapper `` for WCAG 2.5.5 — but a
@@ -1375,14 +1416,16 @@ export function MeasurementList({
) : (
<>
- startEdit(m)}
- aria-label={t("common.edit")}
- >
-
-
+ {canManage && (
+ startEdit(m)}
+ aria-label={t("common.edit")}
+ >
+
+
+ )}
deleteMutation.mutate(m.id)}
diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx
index 82642056b..4c3850b3a 100644
--- a/src/components/mental-health/instrument-card.tsx
+++ b/src/components/mental-health/instrument-card.tsx
@@ -39,6 +39,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import {
useFormatters,
@@ -71,6 +72,10 @@ export function InstrumentCard({
}) {
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
+ // v1.36.x — a screener is answered by the person it is about. No grant
+ // level admits it, so inside somebody else's record the card reads as
+ // history and carries no way to start one.
+ const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -173,11 +178,13 @@ export function InstrumentCard({
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
info control, so the Start button stands alone with clean space. */}
-
-
- {t("mentalHealth.start")}
-
-
+ {canManage && (
+
+
+ {t("mentalHealth.start")}
+
+
+ )}
);
diff --git a/src/components/mental-health/instrument-detail.tsx b/src/components/mental-health/instrument-detail.tsx
index 385f2b40f..077309011 100644
--- a/src/components/mental-health/instrument-detail.tsx
+++ b/src/components/mental-health/instrument-detail.tsx
@@ -10,6 +10,7 @@
* landing stays a quiet card grid, so the trend is opt-in behind a
* deliberate click, never pushed at the moment someone arrives to check in.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import {
useFormatters,
@@ -35,6 +36,8 @@ export function InstrumentDetail({
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
+ // v1.36.x — see the card: starting a screener is not a delegated verb.
+ const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -73,14 +76,16 @@ export function InstrumentDetail({
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
-
- {t("mentalHealth.start")}
-
+ {canManage && (
+
+ {t("mentalHealth.start")}
+
+ )}
diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx
index 554b15cf3..7cd920d1c 100644
--- a/src/components/mood/mood-list.tsx
+++ b/src/components/mood/mood-list.tsx
@@ -2,6 +2,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
@@ -139,6 +140,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
+ // v1.36.x — mood is not a delegated verb. Inside somebody else's record the
+ // list stays readable and every control that would change it is absent.
+ const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
const [moodFilter, setMoodFilterRaw] = useState("ALL");
// v1.15.13 — management-list source filter + optional date range.
@@ -618,7 +622,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
>
{t("mood.emptyResetFilter")}
- ) : onAddFirst ? (
+ ) : onAddFirst && canManage ? (
{t("mood.emptyAddFirst")}
@@ -644,18 +648,20 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{/* v1.15.13 — select-all-on-page header checkbox. */}
-
+ {canManage && (
+
+ )}
- onToggleRow(entry.id)}
- aria-label={t("dataList.selectRow")}
- />
+ {canManage && (
+ onToggleRow(entry.id)}
+ aria-label={t("dataList.selectRow")}
+ />
+ )}
{entry.score}{" "}
@@ -737,15 +745,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
- startEdit(entry)}
- aria-label={t("common.edit")}
- >
-
-
+ {canManage && (
+ startEdit(entry)}
+ aria-label={t("common.edit")}
+ >
+
+
+ )}
deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
@@ -789,14 +799,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
stays the single control and owns the 44px hit area
via an `after` hit-slop (clicks on a pseudo-element
hit-test against its host button). */}
-
startEdit(entry)}
- aria-label={t("common.edit")}
- >
-
-
+ {canManage && (
+ startEdit(entry)}
+ aria-label={t("common.edit")}
+ >
+
+
+ )}
deleteMutation.mutate(entry.id)}
iconClassName="h-4 w-4"
From ab53d42874dee54f911366a29cc132b98324254a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 3 Aug 2026 12:11:52 +0200
Subject: [PATCH 4/7] Keep dose marking under a switch, and nothing else on
medications
Marking a dose taken or skipped is the verb the delegation was written for:
somebody looking after a parent confirms the morning tablet. Adding a
medication and noting a side effect come with it. The card menu keeps only
what survives and disappears entirely when nothing does.
Four detail tabs leave the registry inside somebody else's record, because
their whole content is owner work: the schedule editor and its plan history,
the supply ledger, the external-token row, and the advanced surface holding
the wizard, the import and the destructive zone. A stale deep link into one
lands on the landing tab, the way an inapplicable tab already does.
The take-all-due sweep goes too. It rides the bulk intake route, which the
delegation does not name, and a control that half-works is worse than one
that is not offered.
---
src/app/medications/page-client.tsx | 90 ++++---
src/components/dashboard/dashboard-header.tsx | 116 +++++----
.../medications/__tests__/card-parts.test.tsx | 14 +-
.../card-parts/medication-intake-actions.tsx | 6 +
.../detail/medication-detail-tabs.tsx | 27 +-
.../medications/dose-history-ledger.tsx | 234 ++++++++++--------
.../medications/intake-history-editable.tsx | 5 +-
.../medications/intake-history-list-v2.tsx | 4 +
.../medications/log-intake-dialog.tsx | 3 +
.../medications/medication-card-menu.tsx | 32 ++-
.../medications/medication-table.tsx | 6 +-
.../sections/intake-history-preview.tsx | 25 +-
.../medications/side-effects-section.tsx | 50 ++--
.../medications/use-medication-intake.ts | 38 ++-
14 files changed, 394 insertions(+), 256 deletions(-)
diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx
index 8013d1f2b..544d2a86c 100644
--- a/src/app/medications/page-client.tsx
+++ b/src/app/medications/page-client.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
@@ -159,6 +160,11 @@ function MedicationCardSkeleton() {
export default function MedicationsPageClient() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
+ // v1.36.x — adding a medication and marking a dose are both admitted
+ // delegated writes, so a WRITE delegate keeps the Add menu whole. The
+ // take-all-due sweep is not (it rides the bulk intake route), and neither is
+ // the customize page, which sharing does not cover at all.
+ const { canAdd, canManage } = useRecordCapabilities();
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
@@ -417,7 +423,7 @@ export default function MedicationsPageClient() {
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
- {dueMeds.length >= 2 && (
+ {canManage && dueMeds.length >= 2 && (
setTakeAllOpen(true)}
@@ -433,47 +439,51 @@ export default function MedicationsPageClient() {
Same glyph, slot (left of the add button) and responsive
44-px mobile tap floor as the dashboard and insights
headers. */}
-
-
-
-
-
+
+
+
+
+ )}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
(incl. a backdated one) against an existing medication, or
create a new medication. v1.12.2 — match the dashboard Add
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
-
-
-
-
- {t("medications.addMedication")}
-
-
-
- setLogIntakeOpen(true)}
- disabled={activeMeds.length === 0}
- >
-
- {t("medications.addChoice.logIntake")}
-
-
-
- {t("medications.addChoice.newMedication")}
-
-
-
+ {canAdd && (
+
+
+
+
+ {t("medications.addMedication")}
+
+
+
+ setLogIntakeOpen(true)}
+ disabled={activeMeds.length === 0}
+ >
+
+ {t("medications.addChoice.logIntake")}
+
+
+
+ {t("medications.addChoice.newMedication")}
+
+
+
+ )}
@@ -511,10 +521,12 @@ export default function MedicationsPageClient() {
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
-
-
- {t("medications.firstMedication")}
-
+ canAdd ? (
+
+
+ {t("medications.firstMedication")}
+
+ ) : undefined
}
/>
) : tableView ? (
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index 77138eece..868359f06 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -25,6 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
@@ -37,6 +38,11 @@ export function DashboardHeader({
const { user } = useAuth();
const mounted = useMounted();
const nutrientsEnabled = useModuleEnabled("nutrients");
+ // v1.36.x — the dashboard quick-add offers four kinds; a delegation admits
+ // two of them (a reading, a dose). Mood and water stay with the account
+ // whose record it is, and the customize shortcut points at a settings page
+ // sharing does not cover at all.
+ const { canAdd, canManage } = useRecordCapabilities();
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -87,24 +93,27 @@ export function DashboardHeader({
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
touch-target contract the add button also honours, shrinking
back to the 40 px icon footprint on sm+. */}
-
-
-
-
-
-
-
- {/* v1.4.33 maintainer-item-7 — restore proportional sizing
+
+
+
+
+ )}
+ {canAdd && (
+
+
+ {/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
@@ -116,53 +125,56 @@ export function DashboardHeader({
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
-
+
+ {t("common.add")}
+
+
+
-
- {t("common.add")}
-
-
-
- {/* Menu items must each carry a self-contained verb-phrase
+ {/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
- onQuickEntry("measurement")}>
-
- {t("dashboard.quickAddMeasurement")}
-
- onQuickEntry("mood")}>
-
- {t("dashboard.quickAddMood")}
-
- {/* v1.4.37 W7b — third quick-add row: medication intake.
+ onQuickEntry("measurement")}>
+
+ {t("dashboard.quickAddMeasurement")}
+
+ {canManage && (
+ onQuickEntry("mood")}>
+
+ {t("dashboard.quickAddMood")}
+
+ )}
+ {/* v1.4.37 W7b — third quick-add row: medication intake.
Same Sheet-on-mobile / Dialog-on-desktop primitive as
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
- onQuickEntry("medicationIntake")}
- >
-
- {t("dashboard.quickAddMedicationIntake")}
-
- {nutrientsEnabled && (
- onQuickEntry("water")}>
-
- {t("dashboard.quickAddWater")}
+ onQuickEntry("medicationIntake")}
+ >
+
+ {t("dashboard.quickAddMedicationIntake")}
- )}
-
-
+ {nutrientsEnabled && canManage && (
+ onQuickEntry("water")}>
+
+ {t("dashboard.quickAddWater")}
+
+ )}
+
+
+ )}
>
}
/>
diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx
index f5721b897..50598c3c3 100644
--- a/src/components/medications/__tests__/card-parts.test.tsx
+++ b/src/components/medications/__tests__/card-parts.test.tsx
@@ -40,10 +40,16 @@ function makeClient(): QueryClient {
}
function render(node: React.ReactNode, client?: QueryClient) {
- const tree = client ? (
- {node}
- ) : (
- node
+ // v1.36.x — every card part now asks what the record on screen allows
+ // before it renders a control, and that answer rides the account query.
+ // The parts that carry no query of their own were rendered here without a
+ // provider; they need one now. Fixtures are the caller's own record, which
+ // is every capability — the delegation cases live in
+ // `src/components/__tests__/delegated-write-affordances.test.tsx`.
+ const tree = (
+
+ {node}
+
);
return renderToStaticMarkup(
{tree},
diff --git a/src/components/medications/card-parts/medication-intake-actions.tsx b/src/components/medications/card-parts/medication-intake-actions.tsx
index bb7dfba5d..c25cbda1a 100644
--- a/src/components/medications/card-parts/medication-intake-actions.tsx
+++ b/src/components/medications/card-parts/medication-intake-actions.tsx
@@ -2,6 +2,7 @@ import { Check, Loader2, SkipForward } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
interface MedicationIntakeActionsProps {
/** "take" | "skip" while the matching request is in flight, else null. */
@@ -27,6 +28,11 @@ export function MedicationIntakeActions({
onRecordIntake,
}: MedicationIntakeActionsProps) {
const { t } = useTranslations();
+ // v1.36.x — marking a dose taken or skipped is the verb the delegation was
+ // written for. A read-only delegate sees the card and its schedule and no
+ // action row; the card is otherwise unchanged.
+ const { canAdd } = useRecordCapabilities();
+ if (!canAdd) return null;
return (
diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index 9454035c6..684bb14e3 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -31,6 +31,7 @@
* administration.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
useCallback,
useEffect,
@@ -171,6 +172,19 @@ const TAB_SLUGS = [
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
+/**
+ * Tabs whose entire content is owner-only work: the schedule editor and its
+ * plan history, the supply ledger, the external-token row, and the advanced
+ * surface (wizard, import, pause, end, purge, delete). A delegation admits
+ * none of it at any level.
+ */
+const OWNER_ONLY_TABS: ReadonlySet = new Set([
+ "zeitplan",
+ "bestand",
+ "api",
+ "erweitert",
+]);
+
/**
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
@@ -222,6 +236,7 @@ export function MedicationDetailTabs({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const { canManage } = useRecordCapabilities();
const router = useRouter();
const searchParams = useSearchParams();
@@ -273,15 +288,23 @@ export function MedicationDetailTabs({
// reminds, so the Zeitplan tab (times editor + reminder card +
// plan-history) does not apply and is dropped from the registry; a
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
+ // v1.36.x — four tabs are nothing BUT owner work: the schedule editor, the
+ // supply ledger, the external-token row and the advanced surface that holds
+ // the wizard, the import and the destructive zone. None of it is delegated,
+ // so inside somebody else's record they leave the registry entirely rather
+ // than opening onto controls the server refuses — which also means a stale
+ // `?tab=erweitert` deep link lands on the landing tab, the same way an
+ // inapplicable tab already does.
const availableTabs = useMemo(
() =>
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded) &&
- (slug !== "wirkung" || hasEfficacyTarget),
+ (slug !== "wirkung" || hasEfficacyTarget) &&
+ (canManage || !OWNER_ONLY_TABS.has(slug)),
),
- [isInjectable, asNeeded, hasEfficacyTarget],
+ [isInjectable, asNeeded, hasEfficacyTarget, canManage],
);
const requestedRaw = searchParams?.get("tab");
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index ef6567479..829796da5 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -39,6 +39,7 @@
* the ledger uses the semantic feedback vocabulary on the row, not the card.
*/
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -162,6 +163,10 @@ export function DoseHistoryLedger({
const fmt = useFormatters();
const dateFormatPref = useDateFormatPreference();
const { user } = useAuth();
+ // v1.36.x — a delegate may record a dose, which is what "add" and the
+ // inline "mark taken" do. Editing, deleting, pinning and un-skipping an
+ // existing event are the owner's, so the row menu is absent for them.
+ const { canAdd } = useRecordCapabilities();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
@@ -413,16 +418,18 @@ export function DoseHistoryLedger({
{t("medications.detail.verlauf.summaryEmpty")}
)}
- setAddOpen(true)}
- className="min-h-11 shrink-0 sm:min-h-9"
- data-slot="ledger-add"
- >
-
- {t("medications.detail.verlauf.add")}
-
+ {canAdd && (
+ setAddOpen(true)}
+ className="min-h-11 shrink-0 sm:min-h-9"
+ data-slot="ledger-add"
+ >
+
+ {t("medications.detail.verlauf.add")}
+
+ )}
- setPendingDeleteId(row.id)}
- disabled={deleteMutation.isPending}
- >
-
-
+ {canManage && (
+ setPendingDeleteId(row.id)}
+ disabled={deleteMutation.isPending}
+ >
+
+
+ )}
))}
diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts
index b142b7afc..f9ef5dfab 100644
--- a/src/components/medications/use-medication-intake.ts
+++ b/src/components/medications/use-medication-intake.ts
@@ -1,5 +1,6 @@
"use client";
+import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -94,6 +95,13 @@ export async function runRecordIntake(deps: {
setIntakeLoading: (value: string | null) => void;
undoIntake: (eventId: string) => void | Promise;
onRecorded?: (eventId: string | undefined, skipped: boolean) => void;
+ /**
+ * v1.36.x — the record this dose was written into, when it is not the
+ * caller's own. Two consequences: the toast names it, and the Undo action
+ * goes. A delegate may record a dose and may not remove one, so an Undo
+ * they can see is an Undo the server refuses.
+ */
+ recordName?: string | null;
}): Promise {
const {
medication,
@@ -104,6 +112,7 @@ export async function runRecordIntake(deps: {
setIntakeLoading,
undoIntake,
onRecorded,
+ recordName,
} = deps;
setIntakeLoading(skipped ? "skip" : "take");
@@ -137,14 +146,20 @@ export async function runRecordIntake(deps: {
: "medications.intakeToastTaken",
{ name: medication.name },
),
- eventId
+ recordName
? {
- action: {
- label: t("medications.intakeUndo"),
- onClick: () => void undoIntake(eventId),
- },
+ description: t("recordSharing.toast.savedTo", {
+ name: recordName,
+ }),
}
- : undefined,
+ : eventId
+ ? {
+ action: {
+ label: t("medications.intakeUndo"),
+ onClick: () => void undoIntake(eventId),
+ },
+ }
+ : undefined,
);
await invalidateMedicationReads(queryClient);
onRecorded?.(eventId, skipped);
@@ -195,6 +210,8 @@ export async function runLogIntake(deps: {
* carries no action when it's absent.
*/
undoIntake?: (eventId: string) => void | Promise;
+ /** See {@link runRecordIntake}: names the record, and drops Undo. */
+ recordName?: string | null;
}): Promise {
const {
medication,
@@ -205,6 +222,7 @@ export async function runLogIntake(deps: {
t,
queryClient,
undoIntake,
+ recordName,
} = deps;
try {
const body: Record = { skipped };
@@ -230,7 +248,11 @@ export async function runLogIntake(deps: {
// a real Undo action to attach; keeps the no-undo call signature
// identical to the pre-fix behaviour (existing unit tests assert the
// single-argument call).
- if (eventId && undoIntake) {
+ if (recordName) {
+ toast.success(message, {
+ description: t("recordSharing.toast.savedTo", { name: recordName }),
+ });
+ } else if (eventId && undoIntake) {
toast.success(message, {
action: {
label: t("medications.intakeUndo"),
@@ -273,6 +295,7 @@ export function useMedicationIntake({
}: UseMedicationIntakeParams): UseMedicationIntakeResult {
const queryClient = useQueryClient();
const { t } = useTranslations();
+ const recordName = useActiveRecordName();
const [intakeLoading, setIntakeLoading] = useState(null);
const undoIntake = (eventId: string) =>
@@ -288,6 +311,7 @@ export function useMedicationIntake({
setIntakeLoading,
undoIntake,
onRecorded,
+ recordName,
});
return { intakeLoading, recordIntake, undoIntake };
From d5a9360c52707313cbc1849544602bbbe9476eee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 3 Aug 2026 12:12:08 +0200
Subject: [PATCH 5/7] 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.
---
messages/de.json | 25 ++++
messages/en.json | 25 ++++
messages/es.json | 25 ++++
messages/fr.json | 25 ++++
messages/it.json | 25 ++++
messages/pl.json | 25 ++++
.../success-affordance-guard.test.ts | 2 +-
src/app/page-client.tsx | 26 +++-
.../__tests__/looking-after-card.test.tsx | 139 ++++++++++++++++++
.../dashboard/looking-after-card.tsx | 129 ++++++++++++++++
src/components/labs/lab-form.tsx | 13 +-
.../measurements/measurement-form.tsx | 16 +-
.../__tests__/activity-verb.test.ts | 77 ++++++++++
src/lib/record-activity/activity-verb.ts | 62 ++++++++
14 files changed, 604 insertions(+), 10 deletions(-)
create mode 100644 src/components/dashboard/__tests__/looking-after-card.test.tsx
create mode 100644 src/components/dashboard/looking-after-card.tsx
create mode 100644 src/lib/record-activity/__tests__/activity-verb.test.ts
create mode 100644 src/lib/record-activity/activity-verb.ts
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={
- setQuickEntryDialog("measurement")}
- >
-
- {t("dashboard.emptyAddMeasurement")}
-
+ canAdd ? (
+ setQuickEntryDialog("measurement")}
+ >
+
+ {t("dashboard.emptyAddMeasurement")}
+
+ ) : 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 (
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 });
+ }
+}
From 59965e141e17c6229069fa2db4d81a85c6c69320 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 3 Aug 2026 12:17:15 +0200
Subject: [PATCH 6/7] Drive a delegated write through the browser, once the
level control lands
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The component suite runs without a DOM renderer, so it proves what a page
paints and never what a button does. That is the right shape for this work,
because the property that matters most is a control being absent — but it
leaves the other half unproven: a form that renders is not a form that
submits, and a write that reaches somebody else's record is exactly the thing
worth watching happen.
The journey stands down while the invitation form has no level control, since
minting a WRITE grant behind the UI's back would prove a path no person can
walk. It names the control it is waiting for, so a run that skips while the
control exists reads as a bug here rather than an absence upstream.
---
e2e/delegated-writes.spec.ts | 189 +++++++++++++++++++++++++++++++++++
1 file changed, 189 insertions(+)
create mode 100644 e2e/delegated-writes.spec.ts
diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts
new file mode 100644
index 000000000..34bd0c476
--- /dev/null
+++ b/e2e/delegated-writes.spec.ts
@@ -0,0 +1,189 @@
+/**
+ * A delegate who may write: what they are offered, and what they are not.
+ *
+ * The unit suite runs SSR-only, so it holds the PAINT and never the click: it
+ * can prove an add button is absent, which is the property that matters most,
+ * and it cannot prove that the one still standing submits. This spec is the
+ * other half — a real form, a real POST, a real row in somebody else's record,
+ * and the owner seeing it afterwards.
+ *
+ * ## Why it gates itself instead of seeding a WRITE grant directly
+ *
+ * The grant level is chosen in the invitation form, which is another chunk's
+ * work. Rather than mint a WRITE row behind the UI's back — which would prove
+ * the journey works for a grant no person can create — the journey looks for
+ * the level control and stands down when it is not there yet.
+ *
+ * **To enable this once the invite form ships its level control:** if it lands
+ * under a different `data-slot` than the constant below, change that one
+ * string. Nothing else in this file assumes anything about the control except
+ * that picking WRITE and submitting mints a WRITE grant.
+ *
+ * The skip is deliberately loud rather than silent: it names the missing
+ * control, so a run where the control exists and the journey still does not
+ * execute reads as a bug in this file rather than as an absence upstream.
+ *
+ * ## What this spec cannot cover
+ *
+ * The owner's activity view is asserted at the level of "a row appeared for
+ * something the delegate did", not at the level of the per-verb sentence. The
+ * verb lines live in `src/lib/record-activity/activity-verb.ts` with their own
+ * unit test; the one line that binds them into the activity card belongs to
+ * the file this chunk was told not to touch.
+ */
+import type { BrowserContext, Page } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import {
+ DELEGATE_STORAGE_STATE_PATH,
+ E2E_OWNER,
+ E2E_USER,
+ OWNER_STORAGE_STATE_PATH,
+} from "./setup/test-helpers";
+
+/**
+ * The invitation form's grant-level control. The journey runs when this is on
+ * the page and stands down when it is not. One string, one place.
+ */
+const GRANT_LEVEL_SLOT = "grant-invite-level";
+
+/** The value the level control carries for a grant that may add entries. */
+const WRITE_LEVEL_VALUE = "write";
+
+test.describe("delegated writes", () => {
+ // One journey in order, like the read-only sibling: each step is the next
+ // one's precondition, and the invitation endpoint is rate-limited.
+ test.describe.configure({ mode: "serial" });
+
+ // The `page` fixture is the DELEGATE throughout, on this journey's own
+ // cookie jar — the switch is stamped on the session row, so switching a
+ // shared jar would switch it for every spec holding the same cookie.
+ test.use({ storageState: DELEGATE_STORAGE_STATE_PATH });
+
+ // Desktop only: the journey mutates shared rows between two seeded accounts,
+ // so two projects running it at once race each other and the second
+ // invitation is refused as a duplicate.
+ test.beforeEach(({}, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "shared-fixture journey — runs once",
+ );
+ });
+
+ let ownerContext: BrowserContext;
+ let ownerPage: Page;
+ let levelControlPresent = false;
+
+ test.beforeAll(async ({ browser }) => {
+ ownerContext = await browser.newContext({
+ storageState: OWNER_STORAGE_STATE_PATH,
+ });
+ ownerPage = await ownerContext.newPage();
+ await ownerPage.goto("/settings/access");
+ levelControlPresent =
+ (await ownerPage.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`).count()) >
+ 0;
+ });
+
+ test.afterAll(async () => {
+ await ownerContext.close();
+ });
+
+ test.beforeEach(() => {
+ test.skip(
+ !levelControlPresent,
+ `no [data-slot="${GRANT_LEVEL_SLOT}"] in the invitation form — a WRITE grant cannot be created through the UI yet`,
+ );
+ });
+
+ test("the owner invites at a level that may add entries", async () => {
+ await ownerPage.goto("/settings/access");
+
+ const identifier = ownerPage.locator(
+ '[data-slot="grant-invite-identifier"]',
+ );
+ const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
+ // The controlled input keeps the submit disabled until React has attached,
+ // so retry the pair rather than waiting a fixed time and hoping.
+ await expect(async () => {
+ await identifier.fill(E2E_USER.username);
+ await expect(submit).toBeEnabled({ timeout: 1000 });
+ }).toPass({ timeout: 15_000 });
+
+ await ownerPage
+ .locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`)
+ .selectOption(WRITE_LEVEL_VALUE);
+
+ // Read the posted body: a level control that renders and sends a hardcoded
+ // level would pass every render assertion and ship a read-only grant.
+ const invitePost = ownerPage.waitForRequest(
+ (req) =>
+ req.method() === "POST" && req.url().endsWith("/api/account/grants"),
+ );
+ await submit.click();
+ const posted = JSON.parse((await invitePost).postData() ?? "{}") as {
+ access?: string;
+ };
+ expect(
+ posted.access?.toLowerCase(),
+ "the invitation must carry the level the owner chose",
+ ).toBe(WRITE_LEVEL_VALUE);
+ });
+
+ test("the delegate accepts and opens the record", async ({ page }) => {
+ await page.goto("/settings/access");
+ await page.locator('[data-slot="grant-accept"]').first().click();
+
+ // The switcher lives inside the user menu, like its read-only sibling.
+ await page.getByRole("button", { name: "User menu" }).first().click();
+ await page.locator('[data-slot="account-switcher-trigger"]').click();
+ await expect(
+ page.locator('[data-slot="account-switcher-menu"]'),
+ ).toBeVisible();
+ await page.locator('[data-slot="account-switcher-entry"]').click();
+
+ const banner = page.locator('[data-slot="shared-record-banner"]');
+ await expect(banner).toBeVisible();
+ await expect(banner).toContainText(E2E_OWNER.username);
+ // The banner drops its read-only clause on its own once the level says so.
+ await expect(banner).not.toContainText("read it, not change it");
+ });
+
+ test("the reading they add lands in the owner's record", async ({ page }) => {
+ await page.goto("/measurements");
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+
+ // The add path survives at WRITE. This is the click an SSR test cannot
+ // make: the button rendering and the button working are two facts.
+ const post = page.waitForResponse(
+ (res) =>
+ res.request().method() === "POST" &&
+ res.url().endsWith("/api/measurements"),
+ );
+ await page.getByRole("button", { name: /add measurement/i }).click();
+ await page.locator('input[name="value"], #value').first().fill("71.5");
+ await page.getByRole("button", { name: /^save$/i }).click();
+ expect((await post).status(), "the write must be accepted").toBeLessThan(
+ 300,
+ );
+
+ // And the row it created is not theirs to change. Absent, not disabled:
+ // a `toBeDisabled()` assertion here would pass against the exact design
+ // this release exists to avoid.
+ await expect(
+ page.locator('[data-slot="selection-action-bar"]'),
+ ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /^delete$/i })).toHaveCount(
+ 0,
+ );
+ });
+
+ test("the owner sees that somebody else was in their record", async () => {
+ await ownerPage.goto("/settings/access");
+ const rows = ownerPage.locator('[data-slot="record-activity-row"]');
+ await expect(rows.first()).toBeVisible();
+ await expect(rows.first()).toContainText(E2E_USER.username);
+ });
+});
From 748def03bd778229c3d94b76fafa5c0491b7cc4b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 3 Aug 2026 12:24:05 +0200
Subject: [PATCH 7/7] Say what somebody did, not that they did something
The activity view told an owner that a delegate acted on their record. With
delegated writing it can say which act: added a reading, recorded a lab result,
marked a dose. The mapping already existed as a pure module with its own test
and a fallback for anything unmapped; this is the wire it was missing.
---
src/components/settings/access/record-activity-card.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx
index 066ce1e30..e30a5ef8b 100644
--- a/src/components/settings/access/record-activity-card.tsx
+++ b/src/components/settings/access/record-activity-card.tsx
@@ -7,6 +7,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
import { accountLabel } from "@/lib/sharing/account-access-view";
import {
useRecordActivity,
@@ -113,7 +114,7 @@ function ActivityRow({ entry }: { entry: RecordActivityEntry }) {
>
{entry.accesses === null
- ? t("recordSharing.activity.acted", { name: who })
+ ? recordActivityVerbLine(t, entry.action, who)
: t("recordSharing.activity.opened", {
name: who,
count: entry.accesses,