mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #709 from MBombeck/work/dw-e
Stop offering a delegate buttons the server will refuse
This commit is contained in:
62 files changed
+2435
-748
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -9489,6 +9489,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9489,6 +9489,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9489,6 +9489,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9489,6 +9489,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9489,6 +9489,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9489,6 +9489,31 @@
|
||||
"opened": "{name} otworzył twoją kartę {count}×",
|
||||
"acted": "{name} wprowadził zmianę w twojej karcie",
|
||||
"deletedAccount": "Usunięte konto"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
|
||||
},
|
||||
"lookingAfter": {
|
||||
"title": "Opiekuję się",
|
||||
"description": "Karty, które udostępniły ci inne osoby.",
|
||||
"levelRead": "Może przeglądać",
|
||||
"levelWrite": "Może przeglądać i dodawać",
|
||||
"open": "Otwórz kartę użytkownika {name}"
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}."
|
||||
},
|
||||
"activityVerb": {
|
||||
"measurementCreate": "{name} dodał pomiar",
|
||||
"labResultCreate": "{name} dodał wynik badania",
|
||||
"allergyCreate": "{name} dodał alergię",
|
||||
"familyHistoryCreate": "{name} dodał wpis w wywiadzie rodzinnym",
|
||||
"illnessEpisodeCreate": "{name} dodał chorobę",
|
||||
"biomarkerCreate": "{name} dodał biomarker",
|
||||
"customMetricEntryCreate": "{name} zapisał własny pomiar",
|
||||
"medicationSideEffectCreate": "{name} zanotował działanie niepożądane",
|
||||
"medicationCreate": "{name} dodał lek",
|
||||
"medicationIntake": "{name} zapisał przyjęcie dawki"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,7 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/take-all-due.ts": { "toast.success": 1 },
|
||||
"src/components/medications/use-medication-intake.ts": { "toast.success": 4 },
|
||||
"src/components/medications/use-medication-intake.ts": { "toast.success": 5 },
|
||||
"src/components/medications/wizard/medication-wizard-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
|
||||
+24
-16
@@ -30,12 +30,18 @@ import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicato
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function LabsPage() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
// v1.36.x — entering a lab result by hand is an admitted delegated write.
|
||||
// Scanning a report is not: it files a document and commits through the OCR
|
||||
// route, and neither is delegated. So a WRITE delegate keeps the manual add
|
||||
// and loses the choice menu around it.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -94,27 +100,29 @@ export default function LabsPage() {
|
||||
point, left of the primary Add and linking to the Labs settings
|
||||
page (view, sort order, biomarker CRUD + reorder). Mirrors the
|
||||
medication page header's wrench glyph + slot + 44px tap floor. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/labs"
|
||||
aria-label={t("labs.customize")}
|
||||
title={t("labs.customize")}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href="/settings/layout/labs"
|
||||
aria-label={t("labs.customize")}
|
||||
title={t("labs.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
|
||||
available: scan a document (OCR) or add a value by hand. The scan
|
||||
option only appears when the capability probe reports a usable
|
||||
mode (vision, or local OCR opted-in for text-only providers), so
|
||||
the surface stays simple for everyone else. When scanning is
|
||||
unavailable, Add opens the manual form directly. */}
|
||||
{ocrCapability.data?.available ? (
|
||||
{ocrCapability.data?.available && canManage ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
@@ -133,7 +141,7 @@ export default function LabsPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
) : canAdd ? (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -141,7 +149,7 @@ export default function LabsPage() {
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import {
|
||||
MeasurementForm,
|
||||
@@ -35,6 +36,7 @@ export function resolveMeasurementReturnTo(
|
||||
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -149,17 +151,22 @@ export default function MeasurementsPage() {
|
||||
</span>
|
||||
}
|
||||
description={t("measurements.subtitle")}
|
||||
// v1.36.x — entering a reading is one of the verbs a WRITE grant
|
||||
// admits, so this is the header action a delegate keeps. A read-only
|
||||
// delegate loses it entirely rather than meeting a disabled one.
|
||||
actions={
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setReturnTo(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.addMeasurement")}
|
||||
</Button>
|
||||
canAdd ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setReturnTo(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.addMeasurement")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => 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. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{canAdd && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -511,10 +521,12 @@ export default function MedicationsPageClient() {
|
||||
title={t("medications.emptyTitle")}
|
||||
description={t("medications.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
</Button>
|
||||
canAdd ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : tableView ? (
|
||||
|
||||
+14
-7
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
@@ -20,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function MoodPage() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -95,13 +97,18 @@ export default function MoodPage() {
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
</Button>
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
+19
-7
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LookingAfterCard } from "@/components/dashboard/looking-after-card";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import React, { Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -241,6 +243,7 @@ export default function DashboardPageClient({
|
||||
value: number | null | undefined,
|
||||
): number | null =>
|
||||
value == null ? null : unitDisplay.toDisplayDelta(type, value);
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const [quickEntryDialog, setQuickEntryDialog] =
|
||||
useState<QuickEntryDialog>(null);
|
||||
|
||||
@@ -901,6 +904,13 @@ export default function DashboardPageClient({
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
|
||||
|
||||
{/* v1.36.x — the caregiver's front door, directly under the greeting
|
||||
and above this account's own day. Somebody who opens the app to
|
||||
check on a parent should not have to go through settings to get
|
||||
there. Renders nothing for an account nobody has shared with, and
|
||||
nothing while a switch is already on. */}
|
||||
<LookingAfterCard />
|
||||
|
||||
{/* S2 — the Today hero, promoted above the tile strip (MARC sign-off
|
||||
decision 1). Renders the S1 daily digest: the day's read, the
|
||||
honest score/freshness face, and the worth-a-look rail. Shimmer
|
||||
@@ -1976,13 +1986,15 @@ export default function DashboardPageClient({
|
||||
title={t("dashboard.emptyTitle")}
|
||||
description={t("dashboard.emptyDescription")}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.emptyAddMeasurement")}
|
||||
</Button>
|
||||
canAdd ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.emptyAddMeasurement")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("row delete", () => {
|
||||
const node = (
|
||||
<DeleteButton onConfirm={() => {}} title="Delete?" description="Gone." />
|
||||
);
|
||||
|
||||
it("renders in the caller's own record", () => {
|
||||
expect(render(OWN_RECORD, node)).toContain("<button");
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record, at both levels", () => {
|
||||
// Not disabled. A greyed bin still claims the row is the delegate's to
|
||||
// remove, and it is not — at either grant level, including for a row the
|
||||
// delegate entered themselves.
|
||||
expect(render(READ_ONLY, node)).toBe("");
|
||||
expect(render(WRITABLE, node)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk selection bar", () => {
|
||||
const node = (
|
||||
<SelectionActionBar
|
||||
count={3}
|
||||
onClear={() => {}}
|
||||
onConfirmDelete={() => {}}
|
||||
isDeleting={false}
|
||||
confirmTitle="Delete 3?"
|
||||
confirmBody="Gone."
|
||||
/>
|
||||
);
|
||||
|
||||
it("renders in the caller's own record", () => {
|
||||
expect(render(OWN_RECORD, node)).toContain(
|
||||
'data-slot="selection-action-bar"',
|
||||
);
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record", () => {
|
||||
expect(render(WRITABLE, node)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("marking a dose", () => {
|
||||
const node = (
|
||||
<MedicationIntakeActions intakeLoading={null} onRecordIntake={() => {}} />
|
||||
);
|
||||
|
||||
it("is offered to a delegate who may write", () => {
|
||||
// The verb the delegation was written for: somebody looking after a
|
||||
// parent marks the morning dose taken.
|
||||
expect(render(WRITABLE, node)).toContain("<button");
|
||||
});
|
||||
|
||||
it("is absent for a read-only delegate", () => {
|
||||
expect(render(READ_ONLY, node)).toBe("");
|
||||
});
|
||||
|
||||
it("is offered in the caller's own record", () => {
|
||||
expect(render(OWN_RECORD, node)).toContain("<button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the medication card menu", () => {
|
||||
const node = (
|
||||
<MedicationCardMenu
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onLogSideEffect={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
it("renders its trigger in the caller's own record", () => {
|
||||
expect(render(OWN_RECORD, node)).toContain("<button");
|
||||
});
|
||||
|
||||
it("keeps a trigger for a delegate who may write", () => {
|
||||
// One item survives for them — noting a side effect — so the menu stays.
|
||||
expect(render(WRITABLE, node)).toContain("<button");
|
||||
});
|
||||
|
||||
it("disappears entirely for a read-only delegate", () => {
|
||||
// Every item in it is owner work, so the trigger goes with them rather
|
||||
// than opening onto an empty sheet.
|
||||
expect(render(READ_ONLY, node)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the capture picker's kinds", () => {
|
||||
const ALL = ["measurement", "medication", "mood", "water"] as const;
|
||||
|
||||
it("offers everything in the caller's own record", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: true }, true, [...ALL]),
|
||||
).toEqual(["measurement", "medication", "mood", "water"]);
|
||||
});
|
||||
|
||||
it("offers a delegate only what the delegation admits", () => {
|
||||
// A reading and a dose are admitted verbs. A mood entry and a glass of
|
||||
// water are not, and the server refuses them under a switch.
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: false }, true, [...ALL]),
|
||||
).toEqual(["measurement", "medication"]);
|
||||
});
|
||||
|
||||
it("offers a read-only delegate nothing", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: false, canManage: false }, true, [...ALL]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still honours the module gate for the owner", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: true }, false, [...ALL]),
|
||||
).toEqual(["measurement", "medication", "mood"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -29,6 +30,14 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
|
||||
*/
|
||||
export function CustomMetricList() {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — the section is dropped whole inside somebody else's record.
|
||||
// Defining a metric is not a delegated verb, and every row here links to
|
||||
// `/custom-metrics/{id}`, which sharing does not cover: the shell turns a
|
||||
// delegate away at that route, so the rows would be doors into a wall.
|
||||
// Logging a value against a custom metric IS admitted, but there is no
|
||||
// reachable surface for it under a switch, and inventing one is a read-model
|
||||
// change this does not make.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -47,6 +56,8 @@ export function CustomMetricList() {
|
||||
router.push(`/custom-metrics/${saved.id}`);
|
||||
}
|
||||
|
||||
if (!canManage) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border-border space-y-3 border-t pt-6"
|
||||
|
||||
@@ -70,7 +70,12 @@ export interface CycleCalendarProps {
|
||||
* renders as the distinct light oval instead of the predicted dot.
|
||||
*/
|
||||
confirmedOvulation?: string | null;
|
||||
onSelectDay: (date: string) => void;
|
||||
/**
|
||||
* Open the day. Omitted inside somebody else's record: no grant level
|
||||
* admits a cycle write, so the cells stay readable and stop being controls
|
||||
* rather than opening a sheet whose every button the server would refuse.
|
||||
*/
|
||||
onSelectDay?: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -208,34 +213,8 @@ export function CycleCalendar({
|
||||
? "UNGRADED"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
aria-label={aria}
|
||||
aria-current={isToday ? "date" : undefined}
|
||||
data-flow-level={flowLevel}
|
||||
data-fertile={info?.isFertileWindow ? "true" : undefined}
|
||||
data-predicted={
|
||||
info?.isPredictedPeriod && !info?.isPeriodLogged
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
data-ovulation={
|
||||
isConfirmedOvulation
|
||||
? "confirmed"
|
||||
: isPredictedOvulationDot
|
||||
? "predicted"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onSelectDay(date)}
|
||||
className={cn(
|
||||
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
isToday && "font-semibold",
|
||||
)}
|
||||
>
|
||||
const content = (
|
||||
<>
|
||||
{/* Fertile window: a soft full-cell band (calm fill, not a hard
|
||||
ring) so the window reads as a continuous range across days. */}
|
||||
{info?.isFertileWindow ? (
|
||||
@@ -311,7 +290,47 @@ export function CycleCalendar({
|
||||
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const cellClass = cn(
|
||||
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
|
||||
isToday && "font-semibold",
|
||||
);
|
||||
const cellAttrs = {
|
||||
role: "gridcell" as const,
|
||||
"aria-label": aria,
|
||||
"aria-current": isToday ? ("date" as const) : undefined,
|
||||
"data-flow-level": flowLevel,
|
||||
"data-fertile": info?.isFertileWindow ? "true" : undefined,
|
||||
"data-predicted":
|
||||
info?.isPredictedPeriod && !info?.isPeriodLogged
|
||||
? "true"
|
||||
: undefined,
|
||||
"data-ovulation": isConfirmedOvulation
|
||||
? "confirmed"
|
||||
: isPredictedOvulationDot
|
||||
? "predicted"
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return onSelectDay ? (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
{...cellAttrs}
|
||||
onClick={() => onSelectDay(date)}
|
||||
className={cn(
|
||||
cellClass,
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div key={date} {...cellAttrs} className={cellClass}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
@@ -92,6 +93,10 @@ type CycleTab = (typeof CYCLE_TABS)[number];
|
||||
|
||||
export function CycleView() {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — no grant level admits a cycle write. Inside somebody else's
|
||||
// record the page stays readable and every path into the log-day sheet and
|
||||
// the profile settings is absent, rather than opening onto a refusal.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
|
||||
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
|
||||
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
|
||||
@@ -163,24 +168,28 @@ export function CycleView() {
|
||||
description={t("cycle.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("cycle.tabs.settings")}
|
||||
title={t("cycle.tabs.settings")}
|
||||
data-slot="cycle-settings-wrench"
|
||||
onClick={() => setTab("settings")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("cycle.tabs.settings")}
|
||||
title={t("cycle.tabs.settings")}
|
||||
data-slot="cycle-settings-wrench"
|
||||
onClick={() => setTab("settings")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -260,7 +269,10 @@ export function CycleView() {
|
||||
) : null}
|
||||
{/* Log CTA — first period when no cycle exists yet, next period
|
||||
when the count paused on an overdue cycle. */}
|
||||
{!loading && !calendarError && verdict?.dayOfCycle == null ? (
|
||||
{canManage &&
|
||||
!loading &&
|
||||
!calendarError &&
|
||||
verdict?.dayOfCycle == null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
@@ -315,12 +327,14 @@ export function CycleView() {
|
||||
>
|
||||
{t("cycle.tabs.insights")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
>
|
||||
{t("cycle.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
>
|
||||
{t("cycle.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="calendar" className="mt-4 space-y-4">
|
||||
@@ -359,7 +373,7 @@ export function CycleView() {
|
||||
null)
|
||||
: null
|
||||
}
|
||||
onSelectDay={openSheet}
|
||||
onSelectDay={canManage ? openSheet : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -429,7 +443,7 @@ export function CycleView() {
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data ? (
|
||||
) : profileQuery.data && canManage ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The caregiver's front door, rendered.
|
||||
*
|
||||
* SSR-only, like the rest of the component suite: what is proven is the
|
||||
* paint — who the card lists and when it exists at all. The tap that switches
|
||||
* records is the switcher's existing mutation and is driven end to end in
|
||||
* `e2e/account-sharing.spec.ts`.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - drop the `accounts.length === 0` bail → "an account nobody shares with
|
||||
* sees nothing" goes red.
|
||||
* - drop the `active != null` bail → "no doorway into the room you are in"
|
||||
* goes red.
|
||||
* - render the level from `entry.access` instead of `entry.canWrite` → "the
|
||||
* level is the server's resolved boolean" goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
const GRANDMOTHER = {
|
||||
accountId: "acct-gran",
|
||||
username: "gran",
|
||||
displayName: "Margarethe",
|
||||
access: "read" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
const FATHER = {
|
||||
accountId: "acct-dad",
|
||||
username: "dad",
|
||||
displayName: null,
|
||||
access: "write" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
import { LookingAfterCard } from "../looking-after-card";
|
||||
|
||||
function render(access: AccountAccess): string {
|
||||
mockAccessRef.value = access;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LookingAfterCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LookingAfterCard>", () => {
|
||||
it("an account nobody shares with sees nothing", () => {
|
||||
// The v1.36.0 posture: the feature is invisible until it applies to you.
|
||||
// Not an empty state, not a prompt to ask somebody for access.
|
||||
expect(render({ accounts: [], active: null, canSwitch: false })).toBe("");
|
||||
});
|
||||
|
||||
it("lists every record shared with this account", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER, FATHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="looking-after-card"');
|
||||
expect(html).toContain('data-account-id="acct-gran"');
|
||||
expect(html).toContain('data-account-id="acct-dad"');
|
||||
// The display name where there is one, the username where there is not.
|
||||
expect(html).toContain("Margarethe");
|
||||
expect(html).toContain("dad");
|
||||
});
|
||||
|
||||
it("the level is the server's resolved boolean", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER, FATHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain("Can view and add");
|
||||
expect(html).toContain("Can view");
|
||||
});
|
||||
|
||||
it("a grant labelled write but resolved read reads as read", () => {
|
||||
// `access` describes the row; `canWrite` is the decision the server made
|
||||
// about it this second. A grant that lapsed, or a level this build does
|
||||
// not honour, must not promise an add the next request would refuse.
|
||||
const html = render({
|
||||
accounts: [{ ...FATHER, access: "write", canWrite: false }],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain("Can view");
|
||||
expect(html).not.toContain("Can view and add");
|
||||
});
|
||||
|
||||
it("shows no health data, only a doorway", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
// A preview would put somebody else's readings on this dashboard, outside
|
||||
// the banner frame that says which record you are looking at. The card
|
||||
// carries names and levels and nothing else.
|
||||
expect(html).not.toMatch(/\d+(\.\d+)?\s*(kg|mmHg|bpm)/);
|
||||
});
|
||||
|
||||
it("no doorway into the room you are already in", () => {
|
||||
expect(
|
||||
render({
|
||||
accounts: [GRANDMOTHER],
|
||||
active: GRANDMOTHER,
|
||||
canSwitch: true,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -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+. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
data-testid="dashboard-customize-shortcut"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/dashboard"
|
||||
aria-label={t("dashboard.customizeDashboard")}
|
||||
title={t("dashboard.customizeDashboard")}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
data-testid="dashboard-customize-shortcut"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
<Link
|
||||
href="/settings/layout/dashboard"
|
||||
aria-label={t("dashboard.customizeDashboard")}
|
||||
title={t("dashboard.customizeDashboard")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canAdd && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* 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. */}
|
||||
<Button
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
<Button
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
{/* 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`. */}
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.37 W7b — third quick-add row: medication intake.
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
{canManage && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 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). */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
{nutrientsEnabled && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
|
||||
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddWater")}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{nutrientsEnabled && canManage && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
|
||||
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddWater")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { HeartHandshake, Loader2 } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.36.x — the caregiver's front door.
|
||||
*
|
||||
* ## Why this is on the dashboard and not in settings
|
||||
*
|
||||
* Sharing shipped as a settings panel and a menu three levels deep: user menu
|
||||
* → switcher → the record. That is the right place to MANAGE a grant, and the
|
||||
* wrong place to use one. Somebody who opens this app every morning to check
|
||||
* on a parent does not go to settings to do it, and UI-STANDARDS §10 draws
|
||||
* exactly that line: settings are account utilities, not feature
|
||||
* destinations. Looking after somebody is a feature destination.
|
||||
*
|
||||
* ## What it is not
|
||||
*
|
||||
* It is a doorway, not a preview. No numbers, no last reading, no status.
|
||||
* Putting a slice of somebody else's health record on a delegate's own
|
||||
* dashboard would place it outside the one frame the whole design leans on —
|
||||
* the banner that says which record you are inside. Everything about the
|
||||
* other person is read after the switch, under that banner, or not at all.
|
||||
*
|
||||
* ## Where the content comes from
|
||||
*
|
||||
* `accountAccess.accounts`, the same resolved list the switcher renders, and
|
||||
* the switch is the same mutation the switcher fires. No second mechanism, no
|
||||
* second source of truth about who may open what. An account nobody has
|
||||
* shared with sees nothing at all — no empty state, no invitation to ask
|
||||
* somebody for access (the v1.36.0 posture: the feature is invisible until it
|
||||
* applies to you).
|
||||
*
|
||||
* Design: `Card` + `TileHeader` per UI-STANDARDS §1 and §5 (icon and title in
|
||||
* the foreground colour, title `text-base`), the person's name as content in
|
||||
* `text-foreground` and the grant level as meta in `text-muted-foreground`
|
||||
* per §3, and the §11 row shape — label block `min-w-0 flex-1`, the whole row
|
||||
* a single tap target at the `min-h-11` mobile floor.
|
||||
*/
|
||||
export function LookingAfterCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const access = user?.accountAccess;
|
||||
const accounts = access?.accounts ?? [];
|
||||
|
||||
// Inside somebody else's record the card would be a doorway to the room you
|
||||
// are already in; the banner owns that context and carries the way out.
|
||||
if (access?.active != null) return null;
|
||||
if (accounts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="looking-after-card">
|
||||
<CardContent className="space-y-3">
|
||||
<TileHeader
|
||||
icon={HeartHandshake}
|
||||
title={t("recordSharing.lookingAfter.title")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("recordSharing.lookingAfter.description")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{accounts.map((entry) => (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(entry.accountId)}
|
||||
aria-label={t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
})}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{levelLabel(entry, t)}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant level, in the words the person reading it would use.
|
||||
*
|
||||
* Bound from the server's resolved `canWrite`, never from the `access` label
|
||||
* beside it: the label describes the row, the boolean is the decision, and a
|
||||
* lapsed WRITE grant must read as view-only here exactly as it behaves.
|
||||
*
|
||||
* No "last opened" line: `lastUsedAt` is not on this payload, and a relative
|
||||
* time invented from something else would be a placeholder wearing a fact's
|
||||
* clothes.
|
||||
*/
|
||||
function levelLabel(
|
||||
entry: AccountAccessEntry,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return entry.canWrite
|
||||
? t("recordSharing.lookingAfter.levelWrite")
|
||||
: t("recordSharing.lookingAfter.levelRead");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
|
||||
/**
|
||||
* Shared single-row delete button + confirm dialog for the data-
|
||||
@@ -25,6 +26,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* so the title/description are passed in rather than reaching for a fixed
|
||||
* translation key. `cancelLabel` / `confirmLabel` default to the shared
|
||||
* `common.*` strings.
|
||||
*
|
||||
* v1.36.x — this is the one place a row delete is spelled across the app
|
||||
* (measurements, lab results, biomarkers, allergies, family history, custom
|
||||
* metrics, medication inventory), so the delegation rule is enforced here
|
||||
* rather than at twelve call sites: deleting is the owner's alone, at every
|
||||
* grant level, so inside somebody else's record the trigger is absent. Not
|
||||
* disabled — a greyed bin still claims the row is the delegate's to remove.
|
||||
* A caller that ever needs a delete a delegate MAY perform would have to say
|
||||
* so explicitly, and today there is none.
|
||||
*/
|
||||
export function DeleteButton({
|
||||
onConfirm,
|
||||
@@ -47,6 +57,8 @@ export function DeleteButton({
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
|
||||
/**
|
||||
* Selection action bar for the data-management lists (measurements +
|
||||
@@ -29,6 +30,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
*
|
||||
* The confirm copy is passed in because the title/body differ per surface
|
||||
* (measurements vs mood) and the count is interpolated by the caller.
|
||||
*
|
||||
* v1.36.x — bulk delete is the owner's alone at every grant level, so the bar
|
||||
* is absent inside somebody else's record. The row checkboxes that feed it are
|
||||
* dropped at their lists for the same reason; this guard is the backstop, so a
|
||||
* list that forgets cannot leave a delete button floating over the page.
|
||||
*/
|
||||
export function SelectionActionBar({
|
||||
count,
|
||||
@@ -46,8 +52,9 @@ export function SelectionActionBar({
|
||||
confirmBody: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
|
||||
if (count <= 0) return null;
|
||||
if (count <= 0 || !canManage) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -55,7 +55,8 @@ export function DocumentCard({
|
||||
document: InboundDocumentDto;
|
||||
selected: boolean;
|
||||
/** `range` = extend the selection from the last anchor (shift-click). */
|
||||
onToggleSelected: (id: string, range?: boolean) => void;
|
||||
/** Omitted inside somebody else's record: selection feeds bulk edits. */
|
||||
onToggleSelected?: (id: string, range?: boolean) => void;
|
||||
onOpen: (id: string) => void;
|
||||
/** Delete key on the focused card — undo-able delete owned by the page. */
|
||||
onDelete?: (id: string) => void;
|
||||
@@ -173,23 +174,25 @@ export function DocumentCard({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
// Explicit click handling instead of onCheckedChange: the mouse
|
||||
// event carries `shiftKey` for file-manager range selection.
|
||||
// preventDefault stops Radix's internal toggle (state is fully
|
||||
// controlled by the page's selection set anyway).
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleSelected(document.id, e.shiftKey);
|
||||
}}
|
||||
aria-label={t("documents.card.selectLabel", { title })}
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 transition-opacity",
|
||||
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
selected && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
{onToggleSelected && (
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
// Explicit click handling instead of onCheckedChange: the mouse
|
||||
// event carries `shiftKey` for file-manager range selection.
|
||||
// preventDefault stops Radix's internal toggle (state is fully
|
||||
// controlled by the page's selection set anyway).
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleSelected(document.id, e.shiftKey);
|
||||
}}
|
||||
aria-label={t("documents.card.selectLabel", { title })}
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 transition-opacity",
|
||||
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
selected && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{document.conditionLinks.length > 0 ||
|
||||
document.servingClass === "attachment" ||
|
||||
@@ -266,7 +269,7 @@ export function DocumentCard({
|
||||
const action = documentCardKeyAction(e.key);
|
||||
if (action === "select") {
|
||||
e.preventDefault();
|
||||
onToggleSelected(document.id, e.shiftKey);
|
||||
onToggleSelected?.(document.id, e.shiftKey);
|
||||
} else if (action === "delete") {
|
||||
e.preventDefault();
|
||||
onDelete?.(document.id);
|
||||
@@ -277,7 +280,7 @@ export function DocumentCard({
|
||||
cancelLongPress();
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
longPressFired.current = true;
|
||||
onToggleSelected(document.id);
|
||||
onToggleSelected?.(document.id);
|
||||
}, LONG_PRESS_MS);
|
||||
}}
|
||||
onTouchMove={cancelLongPress}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* copy. A Share action in the footer opens the clinician share-link create
|
||||
* flow (shared `ShareLinkCreateForm`) with this document pre-attached.
|
||||
*/
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -246,6 +247,7 @@ export function DocumentDetailSheet({
|
||||
(fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
|
||||
).length ?? 0;
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const labsModuleEnabled = user?.modules?.labs !== false;
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
@@ -513,15 +515,23 @@ export function DocumentDetailSheet({
|
||||
// labels collapse to icon-only (aria-labelled) so three controls
|
||||
// never overflow the sheet at 360 px; the labels return at `sm+`.
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => remove.mutate(doc.id)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("documents.detail.delete")}
|
||||
</Button>
|
||||
{/* 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 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => remove.mutate(doc.id)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("documents.detail.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{aiEnabled ? (
|
||||
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
|
||||
@@ -546,17 +556,19 @@ export function DocumentDetailSheet({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShareOpen(true)}
|
||||
data-slot="document-share"
|
||||
aria-label={t("documents.detail.share")}
|
||||
>
|
||||
<Share2 className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.share")}
|
||||
</span>
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShareOpen(true)}
|
||||
data-slot="document-share"
|
||||
aria-label={t("documents.detail.share")}
|
||||
>
|
||||
<Share2 className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.share")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
@@ -620,6 +632,10 @@ export function DocumentDetailSheet({
|
||||
|
||||
<DocumentSummaryBlock
|
||||
summary={doc.summary}
|
||||
// The stored summary is read; generating one writes to the
|
||||
// owner's record, so a delegate reads what is there and has no
|
||||
// generate control.
|
||||
canGenerate={canManage}
|
||||
summaryState={doc.summaryState}
|
||||
generatedAtLabel={
|
||||
doc.summaryGeneratedAt
|
||||
@@ -632,41 +648,43 @@ export function DocumentDetailSheet({
|
||||
onGenerate={generateStoredSummary}
|
||||
/>
|
||||
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
autoReadEnabled={autoReadEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
suggestErrorKey={
|
||||
suggest.isError ? documentAiErrorKey(suggest.error) : null
|
||||
}
|
||||
onSummarise={runSummary}
|
||||
summaryPending={summary.isPending}
|
||||
suggestion={suggestion}
|
||||
suggestionKindLabel={suggestionKindLabel}
|
||||
suggestionDateLabel={suggestionDateLabel}
|
||||
appliedFields={appliedFields}
|
||||
onUseTitle={applyTitle}
|
||||
onUseKind={applyKind}
|
||||
onUseDate={applyDate}
|
||||
onDismissSuggestion={() => setSuggestion(null)}
|
||||
summaryOutput={summaryOutput}
|
||||
summaryResult={summary.data ?? null}
|
||||
summaryErrorKey={
|
||||
summary.isError ? documentAiErrorKey(summary.error) : null
|
||||
}
|
||||
onCloseSummary={() => {
|
||||
setSummaryOutput(null);
|
||||
summary.reset();
|
||||
}}
|
||||
hasContentIndex={doc.hasContentIndex}
|
||||
contentIndexSource={doc.contentIndexSource}
|
||||
indexPending={indexDoc.isPending}
|
||||
onIndex={runIndex}
|
||||
/>
|
||||
{canManage && (
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
autoReadEnabled={autoReadEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
suggestErrorKey={
|
||||
suggest.isError ? documentAiErrorKey(suggest.error) : null
|
||||
}
|
||||
onSummarise={runSummary}
|
||||
summaryPending={summary.isPending}
|
||||
suggestion={suggestion}
|
||||
suggestionKindLabel={suggestionKindLabel}
|
||||
suggestionDateLabel={suggestionDateLabel}
|
||||
appliedFields={appliedFields}
|
||||
onUseTitle={applyTitle}
|
||||
onUseKind={applyKind}
|
||||
onUseDate={applyDate}
|
||||
onDismissSuggestion={() => setSuggestion(null)}
|
||||
summaryOutput={summaryOutput}
|
||||
summaryResult={summary.data ?? null}
|
||||
summaryErrorKey={
|
||||
summary.isError ? documentAiErrorKey(summary.error) : null
|
||||
}
|
||||
onCloseSummary={() => {
|
||||
setSummaryOutput(null);
|
||||
summary.reset();
|
||||
}}
|
||||
hasContentIndex={doc.hasContentIndex}
|
||||
contentIndexSource={doc.contentIndexSource}
|
||||
indexPending={indexDoc.isPending}
|
||||
onIndex={runIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mutationError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
@@ -693,6 +711,16 @@ export function DocumentDetailSheet({
|
||||
maxLength={200}
|
||||
placeholder={t("documents.detail.titlePlaceholder")}
|
||||
/>
|
||||
) : !canManage ? (
|
||||
<p
|
||||
id="document-title-input"
|
||||
className={cn(
|
||||
"min-h-10 px-3 py-2 text-sm",
|
||||
doc.title === null && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{doc.title ?? t("documents.detail.titlePlaceholder")}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -724,8 +752,12 @@ export function DocumentDetailSheet({
|
||||
<Label htmlFor="document-kind-select">
|
||||
{t("documents.detail.kindLabel")}
|
||||
</Label>
|
||||
{/* 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. */}
|
||||
<Select
|
||||
value={doc.kind}
|
||||
disabled={!canManage}
|
||||
onValueChange={(value) =>
|
||||
patch.mutate({ kind: value as InboundDocumentKindValue })
|
||||
}
|
||||
@@ -748,6 +780,7 @@ export function DocumentDetailSheet({
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-date-field"
|
||||
disabled={!canManage}
|
||||
value={doc.documentDate ?? ""}
|
||||
onChange={(value) =>
|
||||
patch.mutate({
|
||||
@@ -778,7 +811,7 @@ export function DocumentDetailSheet({
|
||||
{t("documents.detail.noConditions")}
|
||||
</span>
|
||||
) : null}
|
||||
{(episodes.data?.length ?? 0) > 0 ? (
|
||||
{canManage && (episodes.data?.length ?? 0) > 0 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -47,6 +47,7 @@ export function DocumentSummaryBlock({
|
||||
aiEnabled,
|
||||
isGenerating,
|
||||
actionsDisabled,
|
||||
canGenerate = true,
|
||||
onGenerate,
|
||||
}: {
|
||||
/** The stored, decrypted summary, or null when there is none to show. */
|
||||
@@ -59,6 +60,12 @@ export function DocumentSummaryBlock({
|
||||
isGenerating: boolean;
|
||||
/** Capability still resolving — the transport mode isn't known yet. */
|
||||
actionsDisabled: boolean;
|
||||
/**
|
||||
* v1.36.x — false inside somebody else's record: a stored summary is
|
||||
* readable, generating one writes to the owner's record and no grant level
|
||||
* admits it. The state line stays; the button goes.
|
||||
*/
|
||||
canGenerate?: boolean;
|
||||
onGenerate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -103,7 +110,7 @@ export function DocumentSummaryBlock({
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(absentCopyKey(summaryState))}
|
||||
</p>
|
||||
{aiEnabled ? (
|
||||
{aiEnabled && canGenerate ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -63,7 +63,8 @@ export function DocumentTimeline({
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
selectedIds: ReadonlySet<string>;
|
||||
onToggleSelected: (id: string, range?: boolean) => void;
|
||||
/** Omitted inside somebody else's record: selection feeds bulk edits. */
|
||||
onToggleSelected?: (id: string, range?: boolean) => void;
|
||||
onOpen: (id: string) => void;
|
||||
/** Delete key on the focused card — the page owns the undo-able delete. */
|
||||
onDelete?: (id: string) => void;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
|
||||
* the gate server-side — this is a UX redirect, not the security boundary.
|
||||
*/
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
@@ -179,6 +180,11 @@ export function closeDocumentSelectionAfterCoachHandoff(
|
||||
export function DocumentsView() {
|
||||
const { t } = useTranslations();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
// the vault reads and nothing more: no upload path, no selection, no bulk
|
||||
// bar, no corpus backfill.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -369,7 +375,7 @@ export function DocumentsView() {
|
||||
},
|
||||
[episodeIdFilter, t],
|
||||
);
|
||||
const { dropActive } = usePageFileDrop(enqueueFiles);
|
||||
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
|
||||
@@ -381,6 +387,7 @@ export function DocumentsView() {
|
||||
const contentIndex = usage.data?.contentIndex;
|
||||
const canIndexContent = contentIndex?.enabled ?? false;
|
||||
const showIndexAll =
|
||||
canManage &&
|
||||
canIndexContent &&
|
||||
contentIndex !== undefined &&
|
||||
contentIndex.totalCount > 0 &&
|
||||
@@ -749,18 +756,22 @@ export function DocumentsView() {
|
||||
title={t("documents.title")}
|
||||
description={t("documents.subtitle")}
|
||||
actions={
|
||||
<Button onClick={() => uploadInputRef.current?.click()}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.pageUpload")}
|
||||
</Button>
|
||||
canManage ? (
|
||||
<Button onClick={() => uploadInputRef.current?.click()}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.pageUpload")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<UploadZone
|
||||
usage={usage.data}
|
||||
inputRef={uploadInputRef}
|
||||
onFiles={enqueueFiles}
|
||||
/>
|
||||
{canManage && (
|
||||
<UploadZone
|
||||
usage={usage.data}
|
||||
inputRef={uploadInputRef}
|
||||
onFiles={enqueueFiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentFilterBar
|
||||
searchValue={searchDraft}
|
||||
@@ -802,10 +813,12 @@ export function DocumentsView() {
|
||||
description={t("documents.empty.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button onClick={() => uploadInputRef.current?.click()}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.empty.action")}
|
||||
</Button>
|
||||
canManage ? (
|
||||
<Button onClick={() => uploadInputRef.current?.click()}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.empty.action")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : showEmpty && isFiltered ? (
|
||||
@@ -828,9 +841,9 @@ export function DocumentsView() {
|
||||
isFetchingNextPage={list.isFetchingNextPage}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelected={toggleSelected}
|
||||
onToggleSelected={canManage ? toggleSelected : undefined}
|
||||
onOpen={openDetail}
|
||||
onDelete={(id) => deleteBulk([id])}
|
||||
onDelete={canManage ? (id) => deleteBulk([id]) : undefined}
|
||||
highlightId={upload.highlightId}
|
||||
onPrefetch={prefetchDetail}
|
||||
/>
|
||||
@@ -844,7 +857,7 @@ export function DocumentsView() {
|
||||
contentIndexEnabled={usage.data?.contentIndex.enabled}
|
||||
/>
|
||||
|
||||
{selectedIds.size > 0 ? (
|
||||
{canManage && selectedIds.size > 0 ? (
|
||||
<DocumentBulkBar
|
||||
selectedCount={selectedIds.size}
|
||||
episodes={(episodes.data ?? []).map((e) => ({
|
||||
|
||||
@@ -22,18 +22,26 @@
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function usePageFileDrop(onFiles: (files: File[]) => void): {
|
||||
/**
|
||||
* @param onFiles Where dropped or pasted files go. Omitted inside somebody
|
||||
* else's record: uploading is not a delegated verb, so the listeners are
|
||||
* never attached and the drop overlay never appears. Offering the overlay
|
||||
* and swallowing the file would be worse than the browser's own behaviour.
|
||||
*/
|
||||
export function usePageFileDrop(onFiles?: (files: File[]) => void): {
|
||||
dropActive: boolean;
|
||||
} {
|
||||
const [dropActive, setDropActive] = useState(false);
|
||||
const depthRef = useRef(0);
|
||||
const onFilesRef = useRef(onFiles);
|
||||
const enabled = onFiles != null;
|
||||
|
||||
useEffect(() => {
|
||||
onFilesRef.current = onFiles;
|
||||
}, [onFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const isFileDrag = (event: DragEvent) =>
|
||||
event.dataTransfer?.types?.includes("Files") ?? false;
|
||||
|
||||
@@ -59,7 +67,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
|
||||
depthRef.current = 0;
|
||||
setDropActive(false);
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length > 0) onFilesRef.current(files);
|
||||
if (files.length > 0) onFilesRef.current?.(files);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || depthRef.current === 0) return;
|
||||
@@ -70,7 +78,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
|
||||
const files = Array.from(event.clipboardData?.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
onFilesRef.current(files);
|
||||
onFilesRef.current?.(files);
|
||||
};
|
||||
|
||||
window.addEventListener("dragenter", onDragEnter);
|
||||
@@ -87,7 +95,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("paste", onPaste);
|
||||
};
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
return { dropActive };
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
* day-log history, paginated server-side); when nothing has been logged it
|
||||
* renders a calm prompt to log a day.
|
||||
*/
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -91,6 +92,8 @@ export function IllnessDayTimeline({
|
||||
onLogDay: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — logging a day is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
|
||||
|
||||
const dayLogs = data?.dayLogs ?? [];
|
||||
@@ -117,13 +120,15 @@ export function IllnessDayTimeline({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.empty")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onLogDay}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onLogDay}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setLogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{episode ? (
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onResolve={
|
||||
active && !isChronic
|
||||
? () => resolve.mutate(episode.id)
|
||||
: undefined
|
||||
}
|
||||
resolving={resolve.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setLogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{episode ? (
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onResolve={
|
||||
active && !isChronic
|
||||
? () => resolve.mutate(episode.id)
|
||||
: undefined
|
||||
}
|
||||
resolving={resolve.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{episode?.note ? (
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* per-day timeline and correlation card live. Retrospective-only copy — a
|
||||
* journal, not a medical device, and it does not diagnose.
|
||||
*/
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
|
||||
@@ -73,6 +74,7 @@ function EpisodeCard({
|
||||
view,
|
||||
}: EpisodeCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const fmt = useFormatters();
|
||||
const active = episode.resolvedAt === null;
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
@@ -107,24 +109,26 @@ function EpisodeCard({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -141,16 +145,18 @@ function EpisodeCard({
|
||||
<span className="block truncate">{episode.label}</span>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</CardAction>
|
||||
{canManage && (
|
||||
<CardAction>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex h-full flex-col space-y-3">
|
||||
@@ -205,14 +211,16 @@ function EpisodeCard({
|
||||
flow, promoted from a busy inline cluster. The card navigates to
|
||||
the detail surface on its title link; this is the one explicit
|
||||
button. */}
|
||||
<div className="mt-auto pt-0">
|
||||
<Button
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="mt-auto pt-0">
|
||||
<Button
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -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). */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href="/settings/layout/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
{canAdd && (
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -458,10 +474,12 @@ export function IllnessView() {
|
||||
description={t("illness.empty.body")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
canAdd ? (
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => setEditOpen(true)}
|
||||
disabled={!marker}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
title={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => setEditOpen(true)}
|
||||
disabled={!marker}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
title={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
|
||||
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 }) {
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => 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")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("labs.addResult")}</span>
|
||||
</Button>
|
||||
{/* v1.36.x — entering a result is an admitted delegated write;
|
||||
renaming or removing the marker is not. */}
|
||||
{canAdd && (
|
||||
<Button
|
||||
onClick={() => 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")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("labs.addResult")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -429,9 +437,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
title={t("labs.detail.emptyTitle")}
|
||||
description={t("labs.detail.emptyDescription")}
|
||||
action={
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
{t("labs.addResult")}
|
||||
</Button>
|
||||
canAdd ? (
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
{t("labs.addResult")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -95,6 +96,7 @@ export function LabForm({
|
||||
footerSlot,
|
||||
}: LabFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
|
||||
@@ -199,7 +201,16 @@ export function LabForm({
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
});
|
||||
toast.success(t("labs.form.savedToast"));
|
||||
toast.success(
|
||||
t("labs.form.savedToast"),
|
||||
recordName
|
||||
? {
|
||||
description: t("recordSharing.toast.savedTo", {
|
||||
name: recordName,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (keepOpen) {
|
||||
// A real lab report shares one blood-draw date across every
|
||||
// analyte — clear the reading but deliberately KEEP `takenAt` so
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { 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[] }) {
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
|
||||
// 2.5.5), compact 36px on desktop.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
|
||||
// 2.5.5), compact 36px on desktop.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
|
||||
@@ -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 ? (
|
||||
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ const captureState = vi.hoisted(() => ({
|
||||
nutrientsEnabled: true,
|
||||
}));
|
||||
|
||||
// v1.36.x — the picker asks what the record allows before it offers a kind.
|
||||
// These fixtures are the caller's own record, which is every kind; the
|
||||
// delegation cases are covered in
|
||||
// `src/components/__tests__/delegated-write-affordances.test.tsx`.
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ReactModule>();
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,19 @@ vi.mock("@/hooks/use-module-enabled", () => ({
|
||||
moduleKey === "nutrients" ? moduleGate.nutrientsEnabled : true,
|
||||
}));
|
||||
|
||||
// v1.36.x — the picker asks what the record allows before it offers a kind.
|
||||
// These fixtures are the caller's own record, which is every kind; the
|
||||
// delegation cases are covered in
|
||||
// `src/components/__tests__/delegated-write-affordances.test.tsx`.
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -97,6 +98,8 @@ export function BottomNav() {
|
||||
// the same resolved payload, so the two bars cannot disagree about what a
|
||||
// delegate is offered.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const canCapture = canAdd || canManage;
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
@@ -191,18 +194,24 @@ export function BottomNav() {
|
||||
collar (a `bg-card` ring that punches it out of the bar) plus
|
||||
a stronger shadow so it reads as the bar's primary CTA rather
|
||||
than a fifth flush tab. */}
|
||||
{/* v1.36.x — a read-only delegate has nothing to capture: every
|
||||
surface behind the picker is refused for them, so the bar's
|
||||
primary CTA goes rather than opening onto an empty sheet. At
|
||||
WRITE the picker itself drops the kinds the grant excludes. */}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("nav.capture.title")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={captureOpen}
|
||||
onClick={() => setCaptureOpen(true)}
|
||||
data-testid="bottom-nav-capture"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<Plus className="h-7 w-7" />
|
||||
</button>
|
||||
{canCapture && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("nav.capture.title")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={captureOpen}
|
||||
onClick={() => setCaptureOpen(true)}
|
||||
data-testid="bottom-nav-capture"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<Plus className="h-7 w-7" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{primaryRight.map(renderPrimary)}
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
|
||||
/**
|
||||
* The center "Log" capture action (iOS parity — the bottom bar's
|
||||
@@ -48,6 +52,45 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
|
||||
type CaptureKind = "measurement" | "medication" | "mood" | "water";
|
||||
|
||||
/**
|
||||
* v1.36.x — which of the four capture surfaces a delegation admits.
|
||||
*
|
||||
* A WRITE grant covers entering a reading and marking a dose. It does not
|
||||
* cover a mood entry or a glass of water: those verbs stay on the owner's own
|
||||
* authentication and the server refuses them under a switch. The picker is the
|
||||
* fastest path into all four on a phone, so it is the surface where offering
|
||||
* an unadmitted one costs the most.
|
||||
*/
|
||||
const DELEGABLE_CAPTURE_KINDS: ReadonlySet<CaptureKind> = new Set([
|
||||
"measurement",
|
||||
"medication",
|
||||
]);
|
||||
|
||||
/** The order the chooser lists them in. */
|
||||
const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
"water",
|
||||
];
|
||||
|
||||
/**
|
||||
* The capture kinds to offer, given what the record allows and whether the
|
||||
* nutrients module is on. Exported pure so the delegation rule can be pinned
|
||||
* without opening a sheet: an SSR test cannot tap the button that opens it.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
|
||||
nutrientsEnabled: boolean,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
): CaptureKind[] {
|
||||
return kinds.filter((kind) => {
|
||||
if (kind === "water" && !nutrientsEnabled) return false;
|
||||
if (caps.canManage) return true;
|
||||
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturePickerProps {
|
||||
/** Whether the picker chooser sheet is open. */
|
||||
open: boolean;
|
||||
@@ -58,6 +101,12 @@ interface CapturePickerProps {
|
||||
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const nutrientsEnabled = useModuleEnabled("nutrients");
|
||||
const capabilities = useRecordCapabilities();
|
||||
const offered = visibleCaptureKinds(
|
||||
capabilities,
|
||||
nutrientsEnabled,
|
||||
CAPTURE_KIND_ORDER,
|
||||
);
|
||||
const [kind, setKind] = useState<CaptureKind | null>(null);
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
|
||||
@@ -66,7 +115,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
||||
|
||||
function chooseKind(next: CaptureKind) {
|
||||
if (next === "water" && !nutrientsEnabled) return;
|
||||
if (!offered.includes(next)) return;
|
||||
// Close the chooser first, then open the form sheet so only one
|
||||
// bottom-sheet is mounted at a time (no stacked backdrops).
|
||||
onOpenChange(false);
|
||||
@@ -89,7 +138,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
closeForm();
|
||||
}
|
||||
|
||||
const options: ReadonlyArray<{
|
||||
const allOptions: ReadonlyArray<{
|
||||
kind: CaptureKind;
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -120,6 +169,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
icon: GlassWater,
|
||||
},
|
||||
];
|
||||
const options = allOptions.filter((opt) => offered.includes(opt.kind));
|
||||
|
||||
const formTitleByKind: Record<CaptureKind, string> = {
|
||||
measurement: t("measurements.addMeasurement"),
|
||||
@@ -145,27 +195,25 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
className="grid grid-cols-1 gap-2"
|
||||
data-testid="capture-picker-options"
|
||||
>
|
||||
{options.map((opt) =>
|
||||
opt.kind !== "water" || nutrientsEnabled ? (
|
||||
<button
|
||||
key={opt.kind}
|
||||
type="button"
|
||||
data-testid={`capture-picker-${opt.kind}`}
|
||||
onClick={() => chooseKind(opt.kind)}
|
||||
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<opt.icon className="h-5 w-5" aria-hidden="true" />
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.kind}
|
||||
type="button"
|
||||
data-testid={`capture-picker-${opt.kind}`}
|
||||
onClick={() => chooseKind(opt.kind)}
|
||||
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<opt.icon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{opt.description}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{opt.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
@@ -368,13 +374,13 @@ export function VorsorgeSection({
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
);
|
||||
) : 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 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -389,7 +395,7 @@ export function VorsorgeSection({
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
|
||||
@@ -709,9 +715,11 @@ export function VorsorgeSection({
|
||||
title={t("measurementReminders.empty.title")}
|
||||
description={t("measurementReminders.empty.description")}
|
||||
action={
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
canManage ? (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
) : 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 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -898,14 +907,14 @@ function VorsorgeCard({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
) : 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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
@@ -928,7 +937,7 @@ function VorsorgeCard({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
) : null;
|
||||
|
||||
const deleteDialog = (
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
@@ -1114,7 +1123,9 @@ function VorsorgeCard({
|
||||
)}
|
||||
|
||||
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
|
||||
<div className="mt-auto pt-0">{primaryButton}</div>
|
||||
{primaryButton ? (
|
||||
<div className="mt-auto pt-0">{primaryButton}</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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".
|
||||
<QueryErrorCard onRetry={() => 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.
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
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 ? (
|
||||
<Button variant="outline" onClick={() => setTypeFilter("ALL")}>
|
||||
{t("measurements.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst ? (
|
||||
<Button onClick={onAddFirst}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.emptyAddFirst")}
|
||||
</Button>
|
||||
) : 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. */}
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
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 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTypeFilter("ALL")}
|
||||
>
|
||||
{t("measurements.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst && canAdd ? (
|
||||
<Button onClick={onAddFirst}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.emptyAddFirst")}
|
||||
</Button>
|
||||
) : 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 && (
|
||||
<p
|
||||
data-slot="delegate-add-only-note"
|
||||
className="text-muted-foreground mx-auto max-w-prose text-center text-xs"
|
||||
>
|
||||
{t("recordSharing.delegate.addOnlyNote", {
|
||||
name: delegateName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
|
||||
@@ -978,19 +1013,23 @@ export function MeasurementList({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
/>
|
||||
{/* 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 && (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="w-28">
|
||||
{t("measurements.type")}
|
||||
@@ -1051,7 +1090,7 @@ export function MeasurementList({
|
||||
<TableCell className="pl-4">
|
||||
{/* Grouped/synthetic rows aren't individually
|
||||
deletable, so they have no checkbox. */}
|
||||
{!isGrouped && (
|
||||
{!isGrouped && canManage && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
@@ -1178,15 +1217,17 @@ export function MeasurementList({
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(m.id)
|
||||
@@ -1247,7 +1288,7 @@ export function MeasurementList({
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{/* 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 `<button role=checkbox>`) inside a
|
||||
// 44px wrapper `<button>` for WCAG 2.5.5 — but a
|
||||
@@ -1375,14 +1416,16 @@ export function MeasurementList({
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
iconClassName="h-4 w-4"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
|
||||
@@ -40,10 +40,16 @@ function makeClient(): QueryClient {
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client?: QueryClient) {
|
||||
const tree = client ? (
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
) : (
|
||||
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 = (
|
||||
<QueryClientProvider client={client ?? makeClient()}>
|
||||
{node}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{tree}</I18nProvider>,
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -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<TabSlug> = 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<TabSlug[]>(
|
||||
() =>
|
||||
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");
|
||||
|
||||
@@ -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")}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="ledger-add"
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.verlauf.add")}
|
||||
</Button>
|
||||
{canAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="ledger-add"
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.verlauf.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
@@ -571,6 +578,7 @@ function LedgerRowItem({
|
||||
onUnpin: (eventId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const actionable = isSlotActionable(row);
|
||||
@@ -676,7 +684,7 @@ function LedgerRowItem({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showTakenButton && (
|
||||
{showTakenButton && canAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -697,118 +705,126 @@ function LedgerRowItem({
|
||||
{t("medications.detail.verlauf.markTaken")}
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
|
||||
time: timeLabel,
|
||||
})}
|
||||
data-slot="ledger-row-menu"
|
||||
>
|
||||
<MoreVertical aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" data-slot="ledger-row-menu-content">
|
||||
{actionable && !showTakenButton && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMark(row, "taken")}
|
||||
data-slot="ledger-menu-taken"
|
||||
>
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.markTaken")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{actionable && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMark(row, "skipped")}
|
||||
data-slot="ledger-mark-skipped"
|
||||
>
|
||||
{skipBusy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="mr-2 size-4" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canPin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPin(intake!.id as string, dueContext!.at)}
|
||||
data-slot="ledger-pin"
|
||||
>
|
||||
<Link2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.action", {
|
||||
time: fmt.time(new Date(dueContext!.at)),
|
||||
{canManage && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
|
||||
time: timeLabel,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUnpin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onUnpin(intake!.id as string)}
|
||||
data-slot="ledger-unpin"
|
||||
data-slot="ledger-row-menu"
|
||||
>
|
||||
<Unlink aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.release")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{intake?.id && (
|
||||
<>
|
||||
<MoreVertical aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
data-slot="ledger-row-menu-content"
|
||||
>
|
||||
{actionable && !showTakenButton && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onEdit({
|
||||
id: intake.id as string,
|
||||
takenAt: intake.takenAt,
|
||||
skipped: intake.skipped,
|
||||
scheduledFor: intake.scheduledFor,
|
||||
})
|
||||
}
|
||||
data-slot="ledger-edit"
|
||||
onClick={() => onMark(row, "taken")}
|
||||
data-slot="ledger-menu-taken"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.markTaken")}
|
||||
</DropdownMenuItem>
|
||||
{(row.status === "skipped" ||
|
||||
row.status === "taken_on_time" ||
|
||||
row.status === "taken_late") && (
|
||||
)}
|
||||
{actionable && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMark(row, "skipped")}
|
||||
data-slot="ledger-mark-skipped"
|
||||
>
|
||||
{skipBusy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="mr-2 size-4" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canPin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPin(intake!.id as string, dueContext!.at)}
|
||||
data-slot="ledger-pin"
|
||||
>
|
||||
<Link2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.action", {
|
||||
time: fmt.time(new Date(dueContext!.at)),
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUnpin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onUnpin(intake!.id as string)}
|
||||
data-slot="ledger-unpin"
|
||||
>
|
||||
<Unlink aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.release")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{intake?.id && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onToggleSkipped({
|
||||
onEdit({
|
||||
id: intake.id as string,
|
||||
takenAt: intake.takenAt,
|
||||
skipped: intake.skipped,
|
||||
scheduledFor: intake.scheduledFor,
|
||||
})
|
||||
}
|
||||
data-slot="ledger-toggle-skipped"
|
||||
data-slot="ledger-edit"
|
||||
>
|
||||
{intake.skipped ? (
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="mr-2 size-4" />
|
||||
)}
|
||||
{intake.skipped
|
||||
? t("medications.detail.verlauf.markAsTaken")
|
||||
: t("medications.detail.verlauf.markAsSkipped")}
|
||||
<Pencil aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(intake.id as string)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-slot="ledger-delete"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{(row.status === "skipped" ||
|
||||
row.status === "taken_on_time" ||
|
||||
row.status === "taken_late") && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onToggleSkipped({
|
||||
id: intake.id as string,
|
||||
skipped: intake.skipped,
|
||||
scheduledFor: intake.scheduledFor,
|
||||
})
|
||||
}
|
||||
data-slot="ledger-toggle-skipped"
|
||||
>
|
||||
{intake.skipped ? (
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
) : (
|
||||
<SkipForward
|
||||
aria-hidden="true"
|
||||
className="mr-2 size-4"
|
||||
/>
|
||||
)}
|
||||
{intake.skipped
|
||||
? t("medications.detail.verlauf.markAsTaken")
|
||||
: t("medications.detail.verlauf.markAsSkipped")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(intake.id as string)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-slot="ledger-delete"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* never float to the top of the table (O-1).
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
@@ -61,6 +62,8 @@ export function IntakeHistoryEditable({
|
||||
defaultSortBy,
|
||||
}: IntakeHistoryEditableProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -132,7 +135,7 @@ export function IntakeHistoryEditable({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3" data-slot="intake-history-editable-body">
|
||||
{selected.size > 0 && (
|
||||
{canManage && selected.size > 0 && (
|
||||
<div
|
||||
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
|
||||
role="region"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -586,6 +587,9 @@ function IntakeRowActions({
|
||||
onDeleteIntake?: (eventId: string) => void;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
}) {
|
||||
// v1.36.x — editing or deleting a recorded dose is the owner's.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* the form state + the medication/slot pickers.
|
||||
*/
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -116,6 +117,7 @@ export function LogIntakeDialog({
|
||||
medications,
|
||||
}: LogIntakeDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
@@ -210,6 +212,7 @@ export function LogIntakeDialog({
|
||||
...(doseDeviates && { doseTaken: trimmedDose }),
|
||||
t,
|
||||
queryClient,
|
||||
recordName,
|
||||
// v1.30.1 M7 — a backdated/manual intake now gets the same
|
||||
// Undo affordance the card's take/skip path already has.
|
||||
undoIntake: (eventId) =>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
* navigation.
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -57,6 +58,13 @@ export function MedicationCardMenu({
|
||||
onLogSideEffect,
|
||||
}: MedicationCardMenuProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — noting a side effect is an admitted delegated write; editing the
|
||||
// medication and opening its history editor are not. When nothing in the
|
||||
// menu survives, the trigger goes with it rather than opening onto an empty
|
||||
// sheet. The card itself is untouched: no wash, no badge, no colour change.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const showSideEffect = onLogSideEffect != null && canAdd;
|
||||
if (!canManage && !showSideEffect) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -79,17 +87,21 @@ export function MedicationCardMenu({
|
||||
className="w-56"
|
||||
data-slot="medication-card-menu"
|
||||
>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenHistory}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.header.historyLabel")}
|
||||
</DropdownMenuItem>
|
||||
{onLogSideEffect && (
|
||||
{canManage && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenHistory}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.header.historyLabel")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{showSideEffect && onLogSideEffect && (
|
||||
<>
|
||||
{canManage && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onLogSideEffect}>
|
||||
<Stethoscope className="mr-2 h-4 w-4" />
|
||||
{t("medications.glp1LogSideEffect")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -402,6 +403,9 @@ function MedicationTableRowItem({
|
||||
// failure toast + Undo. No card-specific follow-up here; the
|
||||
// injection-site prompt stays a card affordance.
|
||||
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
|
||||
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
|
||||
// without its action pair.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
|
||||
// SAME batched compliance source as the cards.
|
||||
const { data: compliance } = useMedicationComplianceSummary(medication.id);
|
||||
@@ -675,7 +679,7 @@ function MedicationTableRowItem({
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active ? (
|
||||
{medication.active && canAdd ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* redundant link is removed (R-medui §4.3).
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,6 +35,8 @@ export function IntakeHistoryPreview({
|
||||
onImportOpenChange,
|
||||
}: IntakeHistoryPreviewProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — importing dose history is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -42,16 +45,18 @@ export function IntakeHistoryPreview({
|
||||
title={t("medications.detail.intake.title")}
|
||||
dataSlot="medication-detail-intake-history-section"
|
||||
headerExtras={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onImportOpenChange(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-import"
|
||||
>
|
||||
<Upload aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
canManage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onImportOpenChange(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-import"
|
||||
>
|
||||
<Upload aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<IntakeHistoryEditable medicationId={medicationId} pageSize={14} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -111,6 +112,7 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
||||
|
||||
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -245,17 +247,21 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
({items.length})
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("medications.sideEffects.addCta")}
|
||||
</Button>
|
||||
{/* v1.36.x — noting a side effect is an admitted delegated write.
|
||||
Removing one that is already noted is not. */}
|
||||
{canAdd && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("medications.sideEffects.addCta")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -340,16 +346,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
|
||||
aria-label={t("medications.sideEffects.deleteCta")}
|
||||
onClick={() => setPendingDeleteId(row.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
|
||||
aria-label={t("medications.sideEffects.deleteCta")}
|
||||
onClick={() => setPendingDeleteId(row.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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<void>;
|
||||
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<void> {
|
||||
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<void>;
|
||||
/** See {@link runRecordIntake}: names the record, and drops Undo. */
|
||||
recordName?: string | null;
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
medication,
|
||||
@@ -205,6 +222,7 @@ export async function runLogIntake(deps: {
|
||||
t,
|
||||
queryClient,
|
||||
undoIntake,
|
||||
recordName,
|
||||
} = deps;
|
||||
try {
|
||||
const body: Record<string, unknown> = { 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<string | null>(null);
|
||||
|
||||
const undoIntake = (eventId: string) =>
|
||||
@@ -288,6 +311,7 @@ export function useMedicationIntake({
|
||||
setIntakeLoading,
|
||||
undoIntake,
|
||||
onRecorded,
|
||||
recordName,
|
||||
});
|
||||
|
||||
return { intakeLoading, recordIntake, undoIntake };
|
||||
|
||||
@@ -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. */}
|
||||
<div className="mt-auto pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="mt-auto pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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. */}
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
onClick={onStart}
|
||||
data-slot="instrument-detail-start"
|
||||
>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
onClick={onStart}
|
||||
data-slot="instrument-detail-start"
|
||||
>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<AssessmentHistory rows={rows} instrument={instrument} />
|
||||
|
||||
|
||||
@@ -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<string>("ALL");
|
||||
// v1.15.13 — management-list source filter + optional date range.
|
||||
@@ -618,7 +622,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
>
|
||||
{t("mood.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst ? (
|
||||
) : onAddFirst && canManage ? (
|
||||
<Button size="sm" onClick={onAddFirst}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.emptyAddFirst")}
|
||||
@@ -644,18 +648,20 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
<TableRow>
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
/>
|
||||
{canManage && (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="mood"
|
||||
@@ -695,11 +701,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
{canManage && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{entry.score}{" "}
|
||||
@@ -737,15 +745,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
onConfirm={() => 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). */}
|
||||
<div className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
className="relative after:absolute after:-inset-3.5"
|
||||
/>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
className="relative after:absolute after:-inset-3.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<span
|
||||
data-testid="mood-row-score"
|
||||
@@ -834,15 +846,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
and delete actions meet WCAG 2.5.5 on touch
|
||||
devices. The desktop table keeps its denser
|
||||
h-8 w-8 since pointer targets allow it. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => startEdit(entry)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
iconClassName="h-4 w-4"
|
||||
|
||||
@@ -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 }) {
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{entry.accesses === null
|
||||
? t("recordSharing.activity.acted", { name: who })
|
||||
? recordActivityVerbLine(t, entry.action, who)
|
||||
: t("recordSharing.activity.opened", {
|
||||
name: who,
|
||||
count: entry.accesses,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The owner's ledger says what was done, not that something was.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - drop the `medications.intake.update` case → "each admitted verb has its
|
||||
* own line" goes red on that action.
|
||||
* - make the default arm throw instead of falling back → "an action this
|
||||
* release has never heard of still renders" goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import { recordActivityVerbLine } from "../activity-verb";
|
||||
|
||||
/** Resolve a dotted key against the real EN bundle, params interpolated. */
|
||||
function translate(
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
enMessages,
|
||||
);
|
||||
if (typeof value !== "string") throw new Error(`missing key: ${key}`);
|
||||
return Object.entries(params ?? {}).reduce(
|
||||
(text, [k, v]) => text.replaceAll(`{${k}}`, String(v)),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every action the eleven admitted write verbs can file, with the audit
|
||||
* strings the handlers actually pass to `auditLog()`.
|
||||
*/
|
||||
const ADMITTED_ACTIONS = [
|
||||
"measurement.create",
|
||||
"labResult.create",
|
||||
"allergy.create",
|
||||
"family-history.create",
|
||||
"illness.episode.create",
|
||||
"biomarker.create",
|
||||
"customMetricEntry.create",
|
||||
"medication.sideEffect.create",
|
||||
"medication.create",
|
||||
"medications.intake.update",
|
||||
] as const;
|
||||
|
||||
describe("recordActivityVerbLine", () => {
|
||||
it("each admitted verb has its own line", () => {
|
||||
const generic = translate("recordSharing.activity.acted", { name: "Anna" });
|
||||
const lines = ADMITTED_ACTIONS.map((action) =>
|
||||
recordActivityVerbLine(translate, action, "Anna"),
|
||||
);
|
||||
for (const line of lines) {
|
||||
// The point of the map: none of them may fall through to "made a
|
||||
// change", which is the line the owner already had.
|
||||
expect(line).not.toBe(generic);
|
||||
expect(line).toContain("Anna");
|
||||
}
|
||||
// And no two verbs share a line — a map that answered "added something"
|
||||
// ten times would pass the check above and say nothing.
|
||||
expect(new Set(lines).size).toBe(ADMITTED_ACTIONS.length);
|
||||
});
|
||||
|
||||
it("an action this release has never heard of still renders", () => {
|
||||
// Audit rows outlive this map: a later release files verbs it does not
|
||||
// know, and older rows sit in the same table. Vague beats blank.
|
||||
expect(
|
||||
recordActivityVerbLine(translate, "future.thing.create", "Anna"),
|
||||
).toBe(translate("recordSharing.activity.acted", { name: "Anna" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/** The `t` from `useTranslations()`, injected so this stays pure. */
|
||||
type Translator = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* What somebody did in your record, in a sentence.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* The activity view renders every non-read row as one generic line: "somebody
|
||||
* made a change in your record". That was honest while sharing was read-only
|
||||
* and no delegate could make one. Now a delegate can enter a reading, a lab
|
||||
* result, an allergy, a family-history entry, an illness entry, a biomarker,
|
||||
* a tracked value, a side effect, a medication, and can mark a dose taken —
|
||||
* and "made a change" for ten different things is a way of answering nothing.
|
||||
* An owner reading this at breakfast wants the verb.
|
||||
*
|
||||
* ## Why unknown actions fall back rather than fail
|
||||
*
|
||||
* The action string comes from the audit row, which outlives this map: a
|
||||
* release that admits a twelfth verb writes rows this map has never heard of,
|
||||
* and rows written by an older image sit in the same table. So an unmapped
|
||||
* action renders the generic line it renders today. A blank row would be the
|
||||
* one outcome worse than a vague one.
|
||||
*
|
||||
* Pure and translator-injected so the mapping can be pinned without rendering
|
||||
* a card, and so the keys stay literal for the i18n call-site guard.
|
||||
*/
|
||||
export function recordActivityVerbLine(
|
||||
t: Translator,
|
||||
action: string,
|
||||
name: string,
|
||||
): string {
|
||||
switch (action) {
|
||||
case "measurement.create":
|
||||
return t("recordSharing.activityVerb.measurementCreate", { name });
|
||||
case "labResult.create":
|
||||
return t("recordSharing.activityVerb.labResultCreate", { name });
|
||||
case "allergy.create":
|
||||
return t("recordSharing.activityVerb.allergyCreate", { name });
|
||||
case "family-history.create":
|
||||
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
|
||||
case "illness.episode.create":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
|
||||
case "biomarker.create":
|
||||
return t("recordSharing.activityVerb.biomarkerCreate", { name });
|
||||
case "customMetricEntry.create":
|
||||
return t("recordSharing.activityVerb.customMetricEntryCreate", { name });
|
||||
case "medication.sideEffect.create":
|
||||
return t("recordSharing.activityVerb.medicationSideEffectCreate", {
|
||||
name,
|
||||
});
|
||||
case "medication.create":
|
||||
return t("recordSharing.activityVerb.medicationCreate", { name });
|
||||
case "medications.intake.update":
|
||||
return t("recordSharing.activityVerb.medicationIntake", { name });
|
||||
default:
|
||||
return t("recordSharing.activity.acted", { name });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user