From ebf0c9d6324d4d9068b9ca1e45a8abd13add6a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:22:13 +0200 Subject: [PATCH 01/12] fix(settings): close the dead space above every row of the Appearance list The Appearance hub paints its modules as one divide-y ledger inside a settings card, and the card dropped its padding without dropping its gap. The gap survived between the rows as unclickable space the first row does not have: 24 px on desktop, 16 px on a phone, measured. Rows read a step apart from the top of their own list. - SettingsCard grows a `flush` variant that drops padding AND gap, naming the shape `` already names on the Insights side - the geometry sweep gains a fourth claim: in a divided list card nothing sits between two rows but the divider, and the first row starts at the card's inner edge --- e2e/settings-admin-one-piece.spec.ts | 116 +++++++++++++++++++++ src/components/settings/layout-section.tsx | 2 +- src/components/settings/settings-card.tsx | 23 +++- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/e2e/settings-admin-one-piece.spec.ts b/e2e/settings-admin-one-piece.spec.ts index 95cf8be02..4fc1b69d2 100644 --- a/e2e/settings-admin-one-piece.spec.ts +++ b/e2e/settings-admin-one-piece.spec.ts @@ -21,6 +21,12 @@ import { settleBeforeMeasure } from "./utils/settle"; * 3. **One reading edge.** Every card body starts at the card's inner edge. * The `pl-7` gutter put some bodies 28 px in and some at the edge, on the * same page, one card apart. + * 4. **One row rhythm in a list card.** A card whose rows are a `divide-y` + * ledger has nothing between two rows but the divider line, and its first + * row starts at the card's inner top edge. The card contract carries a + * `gap-4 md:gap-6` for a stack of blocks; dropping the padding without + * dropping the gap leaves that gap standing between the rows as dead, + * unclickable space the top of the list does not have. * * Runs in EN and DE, at desktop and phone widths. German runs 15-25% longer, * and an explainer that wraps in German where English fits is the same defect @@ -210,6 +216,63 @@ async function measureCards(page: import("@playwright/test").Page) { }); } +/** + * Row rhythm inside a `divide-y` list card, per card on the page. + * + * A list card is recognised by its own shape rather than by a class name: + * three or more direct children, no card header among them, and a divider on + * every seam between them. That is the ledger pattern wherever it is written, + * and the three-row floor keeps a header-plus-one-bordered-block card — which + * claim 2 already owns — out of this one. + * + * `topInset` is the space above the FIRST row, measured from the card's inner + * top edge; `rowGaps` is the space between consecutive rows, over and above + * the divider line itself. Both are zero in a correct ledger — which is the + * whole point: the first row and every later row sit the same distance from + * the line above them. + */ +async function measureListCards(page: import("@playwright/test").Page) { + return page.evaluate(() => { + const out: { slot: string; topInset: number; rowGaps: number[] }[] = []; + for (const card of document.querySelectorAll('[data-slot="card"]')) { + if (card.querySelector(":scope > header")) continue; + const rows = Array.from(card.children) as HTMLElement[]; + if (rows.length < 3) continue; + // Tailwind writes the divider on the top edge of every row but the + // first, or on the bottom edge of every row but the last, depending on + // the utility. Accept either — the pattern is the same ledger. + const dividedFromTop = rows + .slice(1) + .every((row) => parseFloat(getComputedStyle(row).borderTopWidth) > 0); + const dividedFromBottom = rows + .slice(0, -1) + .every( + (row) => parseFloat(getComputedStyle(row).borderBottomWidth) > 0, + ); + if (!dividedFromTop && !dividedFromBottom) continue; + const cardStyle = getComputedStyle(card); + const cardInnerTop = + card.getBoundingClientRect().top + + parseFloat(cardStyle.borderTopWidth) + + parseFloat(cardStyle.paddingTop); + out.push({ + slot: card.getAttribute("data-testid") ?? "card", + topInset: rows[0].getBoundingClientRect().top - cardInnerTop, + // The divider is drawn inside the row's border box, so it is already + // inside the measured top edge — a correct ledger reports 0 here. + rowGaps: rows + .slice(1) + .map( + (row, index) => + row.getBoundingClientRect().top - + rows[index].getBoundingClientRect().bottom, + ), + }); + } + return out; + }); +} + for (const locale of ["en", "de"] as const) { test.describe(`one piece — ${locale}`, () => { test(`page headers land at one height across every route (${locale})`, async ({ @@ -402,5 +465,58 @@ for (const locale of ["en", "de"] as const) { expect(offGap, "header→body gap is not the card's own gap").toEqual([]); expect(offEdge, "card body does not start at the card edge").toEqual([]); }); + + test(`list cards keep one row rhythm (${locale})`, async ({ page }) => { + test.setTimeout(420_000); + await page.context().addCookies([ + { + name: "healthlog-locale", + value: locale, + url: process.env.E2E_BASE_URL ?? "http://localhost:3000", + }, + ]); + + const offenders: string[] = []; + let measured = 0; + for (const route of ROUTES) { + const response = await page.goto(route, { + waitUntil: "domcontentloaded", + }); + if (response && response.status() >= 400) continue; + const heading = page + .locator( + 'main h1:visible, main [role="heading"][aria-level="1"]:visible', + ) + .first(); + if (!(await heading.isVisible().catch(() => false))) { + await heading + .waitFor({ state: "visible", timeout: 8_000 }) + .catch(() => {}); + } + await settleBeforeMeasure(page, page.locator("main").first()); + for (const card of await measureListCards(page)) { + measured += 1; + const worstGap = Math.max(0, ...card.rowGaps); + if ( + Math.abs(card.topInset) > TOLERANCE || + worstGap > TOLERANCE || + Math.abs(worstGap - Math.min(...card.rowGaps)) > TOLERANCE + ) { + offenders.push( + `${route} (${card.slot}): first row inset ${card.topInset.toFixed(1)}px, ` + + `between rows ${[...new Set(card.rowGaps.map((g) => g.toFixed(1)))].join("/")}px`, + ); + } + } + } + + console.log(`[${locale}] list cards measured: ${measured}`); + // The sweep has to actually find the ledgers it is about. + expect(measured).toBeGreaterThan(0); + expect( + offenders, + "a divided list card puts dead space above its rows that the first row does not have", + ).toEqual([]); + }); }); } diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index cadd7d626..e794b1f42 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -39,7 +39,7 @@ export function LayoutSection() { ); return ( - + {visibleGroups.map((group) => ( ` with * an `aria-labelledby`) while painting the same shape, rhythm, and padding. + * + * `flush` is for the other body shape: an edge-to-edge `divide-y` ledger whose + * rows carry their own inset. Such a card drops BOTH the padding and the gap. + * Dropping only the padding is the trap — the gap survives as dead, + * unclickable space between the rows that the first row does not have, so the + * list reads a step off itself. `` names the same + * shape on the Insights side. */ const SETTINGS_CARD_SHELL = "flex flex-col gap-4 p-4 md:p-6"; +// Both breakpoints are named explicitly: the `` primitive carries +// `md:gap-6` / `md:py-6`, and a bare `gap-0` / `p-0` loses to a `md:` variant +// from `md` up — which is exactly the width the dead space showed at. +const SETTINGS_CARD_FLUSH_SHELL = + "flex flex-col gap-0 overflow-hidden p-0 md:gap-0 md:p-0"; -type SettingsCardOwnProps = { className?: string }; +type SettingsCardOwnProps = { className?: string; flush?: boolean }; type SettingsCardProps = SettingsCardOwnProps & { as?: E; @@ -40,6 +52,7 @@ type SettingsCardProps = SettingsCardOwnProps & { export function SettingsCard({ as, className, + flush = false, ...props }: SettingsCardProps) { if (as) { @@ -50,7 +63,8 @@ export function SettingsCard({ // Mirror the ui Card shell so a semantic landmark card paints // identically to the default -backed one. className={cn( - "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border p-4 shadow-sm md:gap-6 md:p-6", + "bg-card text-card-foreground flex flex-col rounded-xl border shadow-sm", + flush ? SETTINGS_CARD_FLUSH_SHELL : "gap-4 p-4 md:gap-6 md:p-6", className, )} {...props} @@ -61,7 +75,10 @@ export function SettingsCard({ )} /> ); From 0b09eb6b1eed508d617d211e634606320c15df6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:28:56 +0200 Subject: [PATCH 02/12] test(settings): pin that a flush card drops its gap, not only its padding --- .../__tests__/one-piece-anatomy.test.tsx | 14 +++ .../settings/cycle-tracking-card.tsx | 102 ------------------ 2 files changed, 14 insertions(+), 102 deletions(-) delete mode 100644 src/components/settings/cycle-tracking-card.tsx diff --git a/src/components/settings/__tests__/one-piece-anatomy.test.tsx b/src/components/settings/__tests__/one-piece-anatomy.test.tsx index 6b9c50846..d801cf1e7 100644 --- a/src/components/settings/__tests__/one-piece-anatomy.test.tsx +++ b/src/components/settings/__tests__/one-piece-anatomy.test.tsx @@ -67,6 +67,20 @@ describe("SettingsCard — the container owns the rhythm", () => { const html = render(body); expect(html).toContain("gap-2"); }); + + it("drops the gap as well as the padding on a flush ledger card", () => { + // Dropping only the padding leaves the card's own gap standing between + // the rows as dead space the first row does not have. Both breakpoints + // are named because the `` primitive carries a `md:` gap that a + // bare `gap-0` loses to. + const html = render(rows); + expect(html).toContain("gap-0"); + expect(html).toContain("md:gap-0"); + expect(html).toContain("p-0"); + expect(html).toContain("md:p-0"); + expect(html).not.toMatch(/class="[^"]*\bgap-4\b/); + expect(html).not.toMatch(/class="[^"]*\bp-4\b/); + }); }); describe("SettingsCardHeader — one description sentence, one text run", () => { diff --git a/src/components/settings/cycle-tracking-card.tsx b/src/components/settings/cycle-tracking-card.tsx deleted file mode 100644 index fb5d4bed4..000000000 --- a/src/components/settings/cycle-tracking-card.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; -import { CalendarHeart } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { apiGet, apiPatch } from "@/lib/api/api-fetch"; - -/** - * v1.15.0 — the cycle-tracking enable on-ramp. - * - * The cycle vertical auto-enables for accounts set to female (gender-derived - * default), but everyone else needs a way IN: the gated `/cycle` page bounces - * them home and the in-page settings (where the goal/prefs live) sit behind - * that same gate. This card reads the UNGATED `/api/auth/me/cycle-prefs` so any - * account can flip `cycleTrackingEnabled` on (or a female account can opt out) - * before the gated page is reachable. PATCHes the same prefs route the iOS app - * uses; invalidates the nav gate so the sidebar/bottom-nav entry appears. - */ - -interface CyclePrefsShape { - cycleTrackingEnabled: boolean; -} - -export function CycleTrackingCard({ - isAuthenticated, -}: { - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - - const { data } = useQuery({ - queryKey: queryKeys.cyclePrefs(), - enabled: isAuthenticated, - queryFn: async () => { - return apiGet("/api/auth/me/cycle-prefs"); - }, - }); - - const toggle = useMutation({ - mutationFn: async (enabled: boolean) => { - return apiPatch("/api/auth/me/cycle-prefs", { enabled }); - }, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs() }); - void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() }); - // The nav gate reads the resolved enable state off /auth/me. - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - }, - }); - - const enabled = data?.cycleTrackingEnabled ?? false; - - return ( - - -

{t("settings.cycleTracking.detail")}

- -
-
- - toggle.mutate(v)} - className="mt-0.5 shrink-0" - /> -
- {enabled ? ( - - ) : null} - {toggle.isError ? ( -

- {t("settings.cycleTracking.error")} -

- ) : null} -
-
- ); -} From 13b4a0be0b26eb00970a62586aa29b867133b20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:29:24 +0200 Subject: [PATCH 03/12] refactor(settings): thin the Account tree down to the profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cards on Settings → Account were asking questions that already have a home somewhere else. - cycle tracking is a module, and the Modules hub carries the switch that drives the same `cycleTrackingEnabled` column. The Account card was a second door to one decision, so it goes, and the "manage in Account" deep-link beside the module switch goes with it. That link pointed at a `#cycle-tracking` anchor no element ever rendered, so it never landed anywhere in the first place - changing a password belongs with the second factors and the passkeys. The card moves to Settings → Security and leads that page. It renders before the factor status resolves: it is now the only way to change a password and must not depend on a read it does not need Account keeps the profile, the photo, the identity fields and the AI context. The `settings.cycleTracking.*` strings leave all six bundles. --- messages/de.json | 8 - messages/en.json | 8 - messages/es.json | 8 - messages/fr.json | 8 - messages/it.json | 8 - messages/pl.json | 8 - .../__tests__/modules-section.test.tsx | 15 +- .../password-card-placement.test.tsx | 108 ++++++++++ .../settings/account-section/index.tsx | 171 +--------------- src/components/settings/password-card.tsx | 192 ++++++++++++++++++ .../settings/security-section/index.tsx | 40 ++-- src/components/settings/settings-card.tsx | 6 +- src/lib/modules/registry.ts | 9 +- 13 files changed, 349 insertions(+), 240 deletions(-) create mode 100644 src/components/settings/__tests__/password-card-placement.test.tsx create mode 100644 src/components/settings/password-card.tsx diff --git a/messages/de.json b/messages/de.json index 22e0b9015..d749c64b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6037,14 +6037,6 @@ "createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen." }, "genderOther": "Divers", - "cycleTracking": { - "title": "Zyklus-Tracking", - "description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights.", - "enable": "Zyklus-Tracking aktivieren", - "manage": "Zyklus-Tracking öffnen", - "error": "Zyklus-Tracking konnte nicht aktualisiert werden.", - "detail": "Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.", diff --git a/messages/en.json b/messages/en.json index afacc42d2..3ab28641f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6037,14 +6037,6 @@ "createDetail": "The token is shown once on creation — store it then; it cannot be recovered." }, "genderOther": "Other", - "cycleTracking": { - "title": "Cycle tracking", - "description": "Track your menstrual cycle, predictions, symptoms, and phase insights.", - "enable": "Enable cycle tracking", - "manage": "Open cycle tracking", - "error": "Couldn't update cycle tracking.", - "detail": "On by default for accounts set to female; available to everyone." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.", diff --git a/messages/es.json b/messages/es.json index 7cec1ba45..ff86aae20 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6037,14 +6037,6 @@ "createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar." }, "genderOther": "Otro", - "cycleTracking": { - "title": "Seguimiento del ciclo", - "description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases.", - "enable": "Activar el seguimiento del ciclo", - "manage": "Abrir seguimiento del ciclo", - "error": "No se pudo actualizar el seguimiento del ciclo.", - "detail": "Activado por defecto en cuentas configuradas como femeninas; disponible para todos." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.", diff --git a/messages/fr.json b/messages/fr.json index d91f2cf3d..0731663e4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6037,14 +6037,6 @@ "createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré." }, "genderOther": "Autre", - "cycleTracking": { - "title": "Suivi du cycle", - "description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases.", - "enable": "Activer le suivi du cycle", - "manage": "Ouvrir le suivi du cycle", - "error": "Impossible de mettre à jour le suivi du cycle.", - "detail": "Activé par défaut pour les comptes définis comme féminins ; disponible pour tous." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.", diff --git a/messages/it.json b/messages/it.json index 60bfd0bf4..e4528699f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6037,14 +6037,6 @@ "createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato." }, "genderOther": "Altro", - "cycleTracking": { - "title": "Monitoraggio del ciclo", - "description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi.", - "enable": "Attiva il monitoraggio del ciclo", - "manage": "Apri il monitoraggio del ciclo", - "error": "Impossibile aggiornare il monitoraggio del ciclo.", - "detail": "Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.", diff --git a/messages/pl.json b/messages/pl.json index cd1f4c71e..1e4bc806c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6037,14 +6037,6 @@ "createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać." }, "genderOther": "Inna", - "cycleTracking": { - "title": "Śledzenie cyklu", - "description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz.", - "enable": "Włącz śledzenie cyklu", - "manage": "Otwórz śledzenie cyklu", - "error": "Nie udało się zaktualizować śledzenia cyklu.", - "detail": "Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.", diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 4003760d1..8a6ea9e80 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -163,11 +163,20 @@ describe("", () => { } }); - it("keeps a manage deep-link beside the delegated coach + cycle switches", () => { + it("keeps a manage deep-link beside the delegated coach switch", () => { const html = render(); - // Coach → Coach settings; cycle → the Account cycle-tracking card. + // Coach → Coach settings. Coach is the only delegated module with more + // to configure than on/off (cadence, memory) at a settings address. expect(html).toContain('href="/settings/coach"'); - expect(html).toContain('href="/settings/account#cycle-tracking"'); + }); + + it("offers no Account deep-link beside the cycle switch", () => { + // Cycle used to carry a second enable surface in Settings → Account, + // and this link pointed at it. The switch here drives the same column, + // so the Account card went and the link with it — a deep-link to an + // anchor nothing renders is a door into a wall. + const html = render(); + expect(html).not.toContain("/settings/account#cycle-tracking"); }); it("no longer renders the 'Always on' core-domains card", () => { diff --git a/src/components/settings/__tests__/password-card-placement.test.tsx b/src/components/settings/__tests__/password-card-placement.test.tsx new file mode 100644 index 000000000..ec428f6f3 --- /dev/null +++ b/src/components/settings/__tests__/password-card-placement.test.tsx @@ -0,0 +1,108 @@ +/** + * Where the change-password card lives. + * + * It used to sit at the bottom of Settings → Account, under the profile form. + * Changing a password is the same kind of decision as enrolling a second + * factor, so it moved to Settings → Security. Two things have to hold for + * that move to be real rather than a duplicate: + * + * 1. Security mounts it — and mounts it BEFORE the factor status resolves, + * because it is now the only way to change a password and must not + * depend on a query it does not need. + * 2. Account does not, and no second copy of the dialog exists anywhere. + * + * The second claim is checked against the source tree rather than a render, + * because a render of `` proves the card is absent from the + * one branch the test happened to reach. Sweeping the files proves there is + * exactly one place in the app that can paint it. + */ + +import { describe, it, expect, vi } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: true }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: null, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { PasswordCard } from "../password-card"; +import { SecuritySection } from "../security-section"; + +const SLOT = 'data-slot="settings-password-card"'; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const ROOT = process.cwd(); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === "__tests__") continue; + walk(full, out); + } else if (entry.endsWith(".tsx")) { + out.push(full); + } + } + return out; +} + +describe("change-password card placement", () => { + it("paints its slot", () => { + // Positive control for the two assertions below — without it, a renamed + // slot would make every "is it here / is it gone" check read green. + expect(render()).toContain(SLOT); + }); + + it("is mounted by Settings → Security before the factor status lands", () => { + // The mocked query never resolves, which is the case that matters: the + // MFA read is still in flight (or failing) and the password card is + // reachable regardless. + const html = render(); + expect(html).toContain(SLOT); + }); + + it("has exactly one home in the tree", () => { + const owners = walk(join(ROOT, "src")) + .filter((file) => readFileSync(file, "utf8").includes("PasswordCard")) + .map((file) => file.slice(ROOT.length + 1)) + .sort(); + expect(owners).toEqual([ + "src/components/settings/password-card.tsx", + "src/components/settings/security-section/index.tsx", + ]); + }); + + it("left no password form behind in the Account section", () => { + const account = readFileSync( + join(ROOT, "src/components/settings/account-section/index.tsx"), + "utf8", + ); + for (const marker of [ + "settings.passwordReset", + "settings.changePassword", + "/api/auth/password", + "current-password", + ]) { + expect(account, `Account still carries ${marker}`).not.toContain(marker); + } + }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 280bc5669..5d223b5fd 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -2,11 +2,10 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { Loader2, Save, Shield, User } from "lucide-react"; +import { Loader2, Save, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { SeededFormDiscardDialog, useSeededFormDismissal, @@ -15,7 +14,6 @@ import { Input } from "@/components/ui/input"; import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; -import { PasswordInput } from "@/components/ui/password-input"; import { HeightFieldControl } from "@/components/profile/height-field-control"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; @@ -35,7 +33,6 @@ import { storeTimezone } from "@/lib/timezone-mirror"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { DateFormatSelect } from "@/components/settings/date-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; -import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; import { detectBrowserTimezone } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { @@ -94,17 +91,6 @@ export function AccountSection() { "success" | "warning" | "error" | null >(null); - // Password change dialog state. - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [passwordSaving, setPasswordSaving] = useState(false); - const [passwordMsg, setPasswordMsg] = useState(null); - const [passwordMsgType, setPasswordMsgType] = useState< - "success" | "error" | null - >(null); - const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - // v1.23 — passkey + second-factor management moved to the dedicated // /settings/security hub so "how I secure my account" reads as one place. @@ -298,52 +284,6 @@ export function AccountSection() { setSaving(false); } - async function handleChangePassword(e: React.FormEvent) { - e.preventDefault(); - setPasswordSaving(true); - setPasswordMsg(null); - setPasswordMsgType(null); - - if (newPassword !== confirmPassword) { - setPasswordMsg({ key: "settings.passwordMismatch" }); - setPasswordMsgType("error"); - setPasswordSaving(false); - return; - } - - try { - const res = await apiFetchRaw("/api/auth/password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword, - newPassword, - confirmPassword, - }), - }); - const json = await res.json(); - - if (!res.ok) { - setPasswordMsg( - json.error ? { text: json.error } : { key: "settings.savingError" }, - ); - setPasswordMsgType("error"); - return; - } - - setPasswordMsg({ key: "settings.passwordUpdated" }); - setPasswordMsgType("success"); - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - } catch { - setPasswordMsg({ key: "common.networkError" }); - setPasswordMsgType("error"); - } finally { - setPasswordSaving(false); - } - } - if (!mounted || isLoading) { return (
@@ -584,32 +524,12 @@ export function AccountSection() { provider-configuration screen. */} - {/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this - lets any account opt in (or opt out) before the gated /cycle page is - reachable. */} - + {/* Cycle tracking is a module, and the Modules hub carries its switch. + The second enable surface that used to sit here asked the same + question in a second place. */} - {/* Password card. The action is this card's only one, so it sits in - the card's action row like every other primary. It stays full-width - under `sm` — the German "Passwort ändern" is long enough that a - right-hugging button used to break through the card's right border - on a narrow phone. */} - - - - - - + {/* Changing the password is a security control, so the card lives in + Settings → Security beside the second factors and the passkeys. */} {/* v1.25.7 — active-session management + the security-activity feed live only under Settings → Data & Privacy now; the duplicate cards @@ -619,85 +539,6 @@ export function AccountSection() { Erweitert. It is a maintenance / reset action, not a profile or security control, so it sits beside Research Mode + the danger zone. */} - { - setPasswordDialogOpen(open); - if (!open) { - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - setPasswordMsg(null); - setPasswordMsgType(null); - } - }} - title={t("settings.passwordReset")} - description={t("settings.changePasswordDescription")} - className="sm:max-w-xl" - > -
-
-
- - setCurrentPassword(e.target.value)} - /> -
-
- - setNewPassword(e.target.value)} - /> -
-
- - setConfirmPassword(e.target.value)} - /> -
-
- - {passwordMsg && ( -

- {statusText(passwordMsg, t)} -

- )} - - -
-
(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const [dialogOpen, setDialogOpen] = useState(false); + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + setMsgType(null); + + if (newPassword !== confirmPassword) { + setMsg({ key: "settings.passwordMismatch" }); + setMsgType("error"); + setSaving(false); + return; + } + + try { + const res = await apiFetchRaw("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword, + newPassword, + confirmPassword, + }), + }); + const json = await res.json(); + + if (!res.ok) { + setMsg( + json.error ? { text: json.error } : { key: "settings.savingError" }, + ); + setMsgType("error"); + return; + } + + setMsg({ key: "settings.passwordUpdated" }); + setMsgType("success"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setMsg({ key: "common.networkError" }); + setMsgType("error"); + } finally { + setSaving(false); + } + } + + return ( + <> + {/* The action is this card's only one, so it sits in the card's action + row like every other primary. It stays full-width under `sm` — the + German "Passwort ändern" is long enough that a right-hugging button + used to break through the card's right border on a narrow phone. */} + + + + + + + + { + setDialogOpen(open); + if (!open) { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setMsg(null); + setMsgType(null); + } + }} + title={t("settings.passwordReset")} + description={t("settings.changePasswordDescription")} + className="sm:max-w-xl" + > +
+
+
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+
+ + {msg && ( +

+ {statusText(msg, t)} +

+ )} + + +
+
+ + ); +} diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index f2e9a5ac5..4b439c67b 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +import { PasswordCard } from "@/components/settings/password-card"; import { TotpCard } from "./totp-card"; import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card"; import { PasskeyListSection } from "./passkey-list-section"; @@ -45,28 +46,35 @@ export function SecuritySection() { passkeys != null && passkeys.length === 0; - if (isLoading || !status) { - return ( -
- - - -
- ); - } - return (
{showNudge && } - + {/* The password leads, and it renders before the factor status has + landed — it depends on nothing this page fetches. It is also the + only way to change a password now that the Account card is gone, so + a `/api/auth/me/mfa` that never answers must not take it down with + the cards that do need it. */} + - + {isLoading || !status ? ( + <> + + + + + ) : ( + <> + - + + + + + )}
); } diff --git a/src/components/settings/settings-card.tsx b/src/components/settings/settings-card.tsx index 56a69636b..178a215d5 100644 --- a/src/components/settings/settings-card.tsx +++ b/src/components/settings/settings-card.tsx @@ -63,8 +63,10 @@ export function SettingsCard({ // Mirror the ui Card shell so a semantic landmark card paints // identically to the default -backed one. className={cn( - "bg-card text-card-foreground flex flex-col rounded-xl border shadow-sm", - flush ? SETTINGS_CARD_FLUSH_SHELL : "gap-4 p-4 md:gap-6 md:p-6", + "bg-card text-card-foreground rounded-xl border shadow-sm", + flush + ? SETTINGS_CARD_FLUSH_SHELL + : "flex flex-col gap-4 p-4 md:gap-6 md:p-6", className, )} {...props} diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 16546d4c3..329254702 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -203,12 +203,9 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.cycle.description", category: "tracking", delegatesTo: "cycle", - // The hub switch drives `cycleTrackingEnabled`; the fuller cycle-tracking - // card (goal, predictions, cycle lengths) lives in the Account section. - managedAt: { - href: "/settings/account#cycle-tracking", - labelKey: "settings.sections.account.title", - }, + // No `managedAt`. The switch here drives `cycleTrackingEnabled`, and + // that is the whole of the decision; the goal, predictions and cycle + // lengths live on the /cycle page itself, not in Settings. }, mood: { key: "mood", From beb070c0a0d295845229cdcc26f6d16c0e796a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:33:35 +0200 Subject: [PATCH 04/12] fix(settings): cut five module rows back to one sentence Environment, external assistant access, Documents, mental wellbeing and nutrient intake ran 120-165 characters in German where their thirteen neighbours sat at 30-75. Every one of the five carried the same second sentence, "off by default", which restates what the switch beside the row already shows. The mental-wellbeing row also named two screeners where the module ships four, and its second sentence held the one statement in the app about what happens to the answers. That statement moves to the mental-wellbeing page itself, where somebody is about to answer them. A guard holds every module row to one sentence in all six locales, and under the 120-character guideline in English and German. --- messages/de.json | 13 ++-- messages/en.json | 13 ++-- messages/es.json | 13 ++-- messages/fr.json | 13 ++-- messages/it.json | 13 ++-- messages/pl.json | 13 ++-- .../mental-health/mental-wellbeing.tsx | 11 +++ .../module-description-length.test.ts | 77 +++++++++++++++++++ 8 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 src/components/settings/__tests__/module-description-length.test.ts diff --git a/messages/de.json b/messages/de.json index d749c64b4..d60aaf06c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8497,23 +8497,23 @@ }, "mcp": { "label": "Externer Assistentenzugriff (MCP)", - "description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus." + "description": "Lesezugriff für einen MCP-fähigen Assistenten, über ein begrenztes Token." }, "environment": { "label": "Umwelt", - "description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus." + "description": "Tägliches Wetter und Tageslicht für deinen Ort, neben Stimmung, Schlaf und Vitalwerten." }, "inboundDocuments": { "label": "Dokumente", - "description": "Bewahre jedes medizinische Schreiben, jeden Befund und Scan an einem verschlüsselten, durchsuchbaren Ort auf — verknüpft mit deinen Erkrankungen. Standardmäßig aus." + "description": "Arztbriefe, Befunde und Scans verschlüsselt und durchsuchbar an einem Ort." }, "mentalHealth": { "label": "Seelisches Wohlbefinden", - "description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus." + "description": "Vier freiwillige Selbsttests neben dem Stimmungstagebuch." }, "nutrients": { "label": "Nährstoffzufuhr", - "description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus." + "description": "Tägliche Mikronährstoff-Summen aus Apple Health." } }, "illness": { @@ -9010,7 +9010,8 @@ }, "openDetail": "Details anzeigen", "historyLoadError": "Dein Testverlauf konnte nicht geladen werden", - "historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut." + "historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut.", + "privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht." }, "records": { "aiInclusion": { diff --git a/messages/en.json b/messages/en.json index 3ab28641f..bcf9dbf47 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8497,23 +8497,23 @@ }, "mcp": { "label": "External assistant access (MCP)", - "description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default." + "description": "Read access for an MCP-capable assistant, over a scoped token." }, "environment": { "label": "Environment", - "description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default." + "description": "Daily weather and daylight for your location, beside mood, sleep, and vitals." }, "inboundDocuments": { "label": "Documents", - "description": "Keep every medical letter, report, and scan in one encrypted, searchable place — linked to your conditions. Off by default." + "description": "Medical letters, reports, and scans in one encrypted, searchable place." }, "mentalHealth": { "label": "Mental wellbeing", - "description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach." + "description": "Four opt-in screeners beside mood tracking." }, "nutrients": { "label": "Nutrient intake", - "description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default." + "description": "Daily micronutrient totals synced from Apple Health." } }, "illness": { @@ -9010,7 +9010,8 @@ }, "openDetail": "Show details", "historyLoadError": "Your assessment history could not be loaded", - "historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again." + "historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again.", + "privacyNote": "Your answers are stored encrypted and never reach the AI Coach." }, "records": { "aiInclusion": { diff --git a/messages/es.json b/messages/es.json index ff86aae20..54791c8d1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8497,23 +8497,23 @@ }, "mcp": { "label": "Acceso de asistente externo (MCP)", - "description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada." + "description": "Acceso de lectura para un asistente compatible con MCP, mediante un token limitado." }, "environment": { "label": "Entorno", - "description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto." + "description": "El tiempo y las horas de luz diarias de tu ubicación, junto al ánimo, el sueño y las constantes." }, "inboundDocuments": { "label": "Documentos", - "description": "Guarda cada carta médica, informe y escaneo en un solo lugar cifrado y con búsqueda — vinculado a tus afecciones. Desactivado por defecto." + "description": "Cartas médicas, informes y escaneos en un lugar cifrado y con búsqueda." }, "mentalHealth": { "label": "Bienestar mental", - "description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA." + "description": "Cuatro cuestionarios opcionales, junto al registro de ánimo." }, "nutrients": { "label": "Ingesta de nutrientes", - "description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto." + "description": "Totales diarios de micronutrientes sincronizados desde Apple Health." } }, "illness": { @@ -9010,7 +9010,8 @@ }, "openDetail": "Ver detalles", "historyLoadError": "No se pudo cargar tu historial de evaluaciones", - "historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo." + "historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.", + "privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA." }, "records": { "aiInclusion": { diff --git a/messages/fr.json b/messages/fr.json index 0731663e4..5bebe5ce5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8497,23 +8497,23 @@ }, "mcp": { "label": "Accès assistant externe (MCP)", - "description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut." + "description": "Accès en lecture pour un assistant compatible MCP, via un jeton limité." }, "environment": { "label": "Environnement", - "description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut." + "description": "Météo et durée du jour quotidiennes de votre lieu, à côté de l'humeur, du sommeil et des constantes." }, "inboundDocuments": { "label": "Documents", - "description": "Conservez chaque courrier médical, compte rendu et scan dans un espace chiffré et consultable — lié à vos affections. Désactivé par défaut." + "description": "Courriers médicaux, comptes rendus et scans dans un espace chiffré et consultable." }, "mentalHealth": { "label": "Bien-être mental", - "description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA." + "description": "Quatre questionnaires facultatifs, à côté du suivi de l'humeur." }, "nutrients": { "label": "Apports en nutriments", - "description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut." + "description": "Totaux quotidiens de micronutriments synchronisés depuis Apple Health." } }, "illness": { @@ -9010,7 +9010,8 @@ }, "openDetail": "Voir les détails", "historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé", - "historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie." + "historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.", + "privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA." }, "records": { "aiInclusion": { diff --git a/messages/it.json b/messages/it.json index e4528699f..a453e7c92 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8497,23 +8497,23 @@ }, "mcp": { "label": "Accesso assistente esterno (MCP)", - "description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita." + "description": "Accesso in lettura per un assistente compatibile con MCP, tramite un token limitato." }, "environment": { "label": "Ambiente", - "description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default." + "description": "Meteo e ore di luce giornaliere della tua posizione, accanto a umore, sonno e parametri vitali." }, "inboundDocuments": { "label": "Documenti", - "description": "Conserva ogni lettera medica, referto e scansione in un unico archivio cifrato e ricercabile — collegato alle tue patologie. Disattivato di default." + "description": "Lettere mediche, referti e scansioni in un archivio cifrato e ricercabile." }, "mentalHealth": { "label": "Benessere mentale", - "description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA." + "description": "Quattro questionari facoltativi, accanto al diario dell'umore." }, "nutrients": { "label": "Assunzione di nutrienti", - "description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita." + "description": "Totali giornalieri di micronutrienti sincronizzati da Apple Health." } }, "illness": { @@ -9010,7 +9010,8 @@ }, "openDetail": "Mostra dettagli", "historyLoadError": "Non è stato possibile caricare la cronologia dei test", - "historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova." + "historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.", + "privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA." }, "records": { "aiInclusion": { diff --git a/messages/pl.json b/messages/pl.json index 1e4bc806c..5027504c1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8497,23 +8497,23 @@ }, "mcp": { "label": "Dostęp asystenta zewnętrznego (MCP)", - "description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone." + "description": "Dostęp do odczytu dla asystenta obsługującego MCP, przez token o ograniczonym zakresie." }, "environment": { "label": "Środowisko", - "description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone." + "description": "Dzienna pogoda i długość dnia dla Twojej lokalizacji, obok nastroju, snu i parametrów życiowych." }, "inboundDocuments": { "label": "Dokumenty", - "description": "Przechowuj każde pismo lekarskie, wynik i skan w jednym zaszyfrowanym, przeszukiwalnym miejscu — powiązane z Twoimi schorzeniami. Domyślnie wyłączone." + "description": "Pisma lekarskie, wyniki i skany w jednym zaszyfrowanym, przeszukiwalnym miejscu." }, "mentalHealth": { "label": "Dobrostan psychiczny", - "description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI." + "description": "Cztery dobrowolne testy obok dziennika nastroju." }, "nutrients": { "label": "Spożycie składników odżywczych", - "description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone." + "description": "Dzienne sumy mikroskładników synchronizowane z Apple Health." } }, "illness": { @@ -9010,7 +9010,8 @@ }, "openDetail": "Pokaż szczegóły", "historyLoadError": "Nie udało się wczytać historii testów", - "historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie." + "historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie.", + "privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI." }, "records": { "aiInclusion": { diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index b33f3902f..abbe508d0 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -142,6 +142,17 @@ export function MentalWellbeing() { description={t("mentalHealth.pageDescription")} /> + {/* What happens to the answers. The Modules hub used to carry this + as the second sentence of the module's one-line description, + which was the only place in the app that said it — so it moved + to the screen where somebody is about to answer. */} +

+ {t("mentalHealth.privacyNote")} +

+

{t("mentalHealth.choosePrompt")}

{historyError ? ( diff --git a/src/components/settings/__tests__/module-description-length.test.ts b/src/components/settings/__tests__/module-description-length.test.ts new file mode 100644 index 000000000..3634f2b3b --- /dev/null +++ b/src/components/settings/__tests__/module-description-length.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { MODULE_KEYS } from "@/lib/modules/registry"; +import { locales } from "@/lib/i18n/config"; + +/** + * A module row in the "What you track" hub gets ONE sentence, same as a card + * header's description slot (design standards §3). + * + * Five of the eighteen rows had grown a second sentence — always the same + * one, "Off by default", and in one case a third clause after it. The + * neighbours sat at 30-75 characters and those five at 120-165, so the hub + * read as two different lists stacked on each other. The switch beside the + * row already says whether the module is on, so the sentence was restating + * what the control shows. + * + * The single-sentence half of the rule is checked in every locale: it is a + * structural claim, not a length one, and a translation that re-adds the + * clause is the same defect. The character cap is checked in EN and DE, the + * two the maintainer reads, matching `card-description-length.test.ts`. + */ + +const ROOT = process.cwd(); +const MAX_CHARS = 120; + +function bundle(locale: string): Record { + const raw = readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"); + return JSON.parse(raw).modules; +} + +describe("module hub descriptions", () => { + it("names a description for every module in every locale", () => { + // Positive control: without it, a renamed `modules` namespace would make + // the two checks below iterate an empty set and report green. + for (const locale of locales) { + const modules = bundle(locale); + const missing = MODULE_KEYS.filter( + (key) => !modules[key]?.description?.trim(), + ); + expect(missing, `${locale} is missing descriptions`).toEqual([]); + } + }); + + it("holds each one to a single sentence, in every locale", () => { + const offenders: string[] = []; + for (const locale of locales) { + const modules = bundle(locale); + for (const key of MODULE_KEYS) { + const text = modules[key].description!; + // Drop the closing terminator, then any sentence break left inside + // is a second sentence. A semicolon splice counts — it is how the + // longest of the five carried its third clause. + const body = text.replace(/[.!?…]\s*$/, ""); + if (/[.!?;]\s/.test(body)) { + offenders.push(`${locale}/${key}: ${text}`); + } + } + } + expect(offenders).toEqual([]); + }); + + it("holds each one under the character guideline in EN and DE", () => { + const offenders: string[] = []; + for (const locale of ["en", "de"] as const) { + const modules = bundle(locale); + for (const key of MODULE_KEYS) { + const text = modules[key].description!; + if (text.length > MAX_CHARS) { + offenders.push(`${locale}/${key}: ${text.length} chars`); + } + } + } + expect(offenders).toEqual([]); + }); +}); From 0160052289e4621c938acb12fc27eab7a14dde15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:42:35 +0200 Subject: [PATCH 05/12] =?UTF-8?q?refactor(settings):=20retire=20Research?= =?UTF-8?q?=20Mode=20=E2=80=94=20the=20switch=20governed=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research Mode was the opt-in behind the estimated GLP-1 drug-level curve: turn it on, read a disclaimer, get the chart. The chart stopped consulting the flag several releases ago and has painted for every account since, so the switch on Settings → Advanced changed no screen in either position. An inert control that promises to hide a chart is worse than no control. The curve is simply part of the medication page now. Gone with the switch: the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status DTO and its gate helper, the disclaimer-version stamp, the query key, and the three user columns the acknowledgment wrote (migration 0321). The Privacy page's second button pointed at the retired control and goes too. Settings → Advanced is now what it reads as: reset the data, delete the account. --- messages/de.json | 42 +-- messages/en.json | 42 +-- messages/es.json | 42 +-- messages/fr.json | 42 +-- messages/it.json | 42 +-- messages/pl.json | 42 +-- .../migration.sql | 8 + prisma/schema.prisma | 27 +- .../destructive-control-guard.test.ts | 7 - .../success-affordance-guard.test.ts | 8 +- .../me/disable-coach/__tests__/route.test.ts | 2 +- .../me/research-mode/__tests__/route.test.ts | 300 ------------------ src/app/api/auth/me/research-mode/route.ts | 219 ------------- .../log-injection-site-dialog.test.tsx | 2 +- ...search-mode-acknowledgment-dialog.test.tsx | 291 ----------------- .../research-mode-acknowledgment-dialog.tsx | 264 --------------- .../advanced-account-delete.test.tsx | 4 - .../__tests__/advanced-research-mode.test.tsx | 189 ----------- .../__tests__/modules-section.test.tsx | 2 +- src/components/settings/advanced-section.tsx | 180 +---------- src/components/settings/privacy-section.tsx | 9 +- src/lib/data-wipe/wipe-plan.ts | 3 - src/lib/medications/__tests__/glp1-pk.test.ts | 19 +- .../__tests__/research-mode-types.test.ts | 69 ---- src/lib/medications/glp1-pk.ts | 22 -- src/lib/medications/research-mode-types.ts | 45 --- src/lib/onboarding/disclaimer.ts | 5 +- src/lib/query-keys/settings.ts | 1 - 28 files changed, 57 insertions(+), 1871 deletions(-) create mode 100644 prisma/migrations/0321_drop_research_mode_columns/migration.sql delete mode 100644 src/app/api/auth/me/research-mode/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/research-mode/route.ts delete mode 100644 src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx delete mode 100644 src/components/medications/research-mode-acknowledgment-dialog.tsx delete mode 100644 src/components/settings/__tests__/advanced-research-mode.test.tsx delete mode 100644 src/lib/medications/__tests__/research-mode-types.test.ts delete mode 100644 src/lib/medications/research-mode-types.ts diff --git a/messages/de.json b/messages/de.json index d60aaf06c..4b2738597 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1604,26 +1604,6 @@ } }, "researchMode": { - "dialog": { - "title": "Forschungsmodus aktivieren", - "intro": "Der Forschungsmodus ergänzt deine GLP-1-Medikamentenseite um eine geschätzte Wirkstoff-Verlaufskurve. Lies die Abgrenzung unten, bevor du den Modus einschaltest — die Kurve ist eine Lernhilfe, niemals eine Messung und nie ein medizinischer Rat.", - "whatItIsHeader": "Was das ist", - "whatItIs": "Eine Sägezahnkurve, die die Überlagerung deiner protokollierten Dosen über die Zeit simuliert. Grundlage ist das Ein-Kompartment-Modell nach Bateman mit Absorptions- und Halbwertszeit-Parametern aus den EMA-EPARs der zugelassenen GLP-1-Rezeptoragonisten. Die Y-Achse ist einheitenlos und ohne Beschriftung. Die Form, nicht der Zahlenwert, ist die Aussage der Kurve.", - "whatItIsntHeader": "Was das nicht ist", - "whatItIsnt": "Kein Blutspiegel. Keine Messung. Keine Vorhersage, wie dein Körper tatsächlich reagiert. Keine Grundlage für Dosierungs-, Zeitpunkt- oder Eskalationsentscheidungen. Für alles, was deine Therapie betrifft, sprich mit deiner verschreibenden Ärztin oder deinem Arzt.", - "whyEstimateHeader": "Warum es eine Schätzung ist", - "whyEstimate": "Populations-Pharmakokinetik beschreibt den typischen Patienten. Die individuelle Streuung ist groß — das Tirzepatid-Modell Schneck/Urva 2024 weist rund 22 % interindividuelle Variabilität bei der Absorption und rund 49 % beim Verteilungsvolumen aus. Eine Lehrbuchkurve kann weder dein Gewicht, deine Nierenfunktion, deine Injektionstechnik noch deine Zeitgenauigkeit abbilden. Lies die Kurve als Rhythmus deiner Dosierung, nicht als deinen Spiegel.", - "mdrBoundaryHeader": "Regulatorische Abgrenzung", - "mdrBoundary": "HealthLog stellt öffentliche regulatorische Daten und deine eigenen Einträge dar. Die App stellt keine Prognose, Diagnose, Behandlung oder Therapieempfehlung. Die EU-Verordnung 2017/745 (Medical Device Regulation, MDR) und das Leitfadenpapier MDCG 2021-24 ziehen die Grenze bei Software, die Daten für medizinische Zwecke interpretiert — diese Kurve bleibt klar auf der edukativen Seite dieser Linie. Wenn du das Gefühl hast, die Kurve dränge dich zu einer klinischen Entscheidung, schließe sie.", - "citationsHeader": "Quellen", - "citations": "EMA European Public Assessment Reports für Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta und Bydureon (Abschnitt 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).", - "versionLine": "Disclaimer-Version: {version}", - "acknowledgeCta": "Verstanden — aktivieren", - "cancelCta": "Abbrechen", - "successToast": "Forschungsmodus aktiviert. Die Wirkstoff-Verlaufskurve ist jetzt auf deiner GLP-1-Medikamentenseite sichtbar.", - "errorToast": "Forschungsmodus konnte nicht aktiviert werden. Bitte erneut versuchen.", - "staleVersionToast": "Der Disclaimer wurde aktualisiert, seit diese Seite geladen wurde. Lade die Seite neu, um die aktuelle Fassung zu lesen." - }, "chart": { "title": "Geschätzter Wirkstoffverlauf", "axisLabel": "Geschätzter Spiegel (relativ)", @@ -4591,19 +4571,6 @@ "remainingMany": "Noch {count} Messungen bis zum Trend." }, "settings": { - "researchMode": { - "sectionTitle": "Forschungsmodus", - "subtitle": "Optionale Kurve des geschätzten GLP-1-Wirkstoffspiegels nach EMA-Daten.", - "toggleLabel": "Geschätzte Wirkstoffkurve anzeigen", - "disabledStatus": "Deaktiviert. Die Wirkstoffkurve ist ausgeblendet.", - "enabledStaleStatus": "Der Disclaimer wurde aktualisiert. Bestätige unten erneut, um die Kurve wieder anzuzeigen.", - "acknowledgedOn": "Bestätigt am {date}.", - "rePromptTitle": "Disclaimer aktualisiert", - "rePromptBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn erneut und bestätige, damit die Kurve sichtbar bleibt.", - "rePromptCta": "Disclaimer erneut bestätigen", - "disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen.", - "subtitleDetail": "Nur zur Anschauung — niemals eine Messung oder ein medizinischer Rat." - }, "shell": { "sectionsNav": "Einstellungs-Bereiche", "groups": { @@ -4887,8 +4854,8 @@ }, "advanced": { "title": "Erweitert", - "description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten.", - "subtitle": "Research-Modus, Daten zurücksetzen und Konto löschen." + "description": "Der Notausgang: alle Gesundheitsdaten unwiderruflich löschen oder das Konto auflösen.", + "subtitle": "Daten zurücksetzen und Konto löschen." }, "export": { "title": "Export & Import", @@ -6277,9 +6244,8 @@ }, "posture": { "title": "Datenschutz-Einstellung", - "description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.", - "openAi": "KI-Datenschutzmodus", - "openResearch": "Forschungsmodus" + "description": "Steuere, wie viele deiner Daten an KI-Funktionen weitergegeben werden.", + "openAi": "KI-Datenschutzmodus" } }, "mcp": { diff --git a/messages/en.json b/messages/en.json index bcf9dbf47..de26039d7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1604,26 +1604,6 @@ } }, "researchMode": { - "dialog": { - "title": "Enable Research Mode", - "intro": "Research Mode adds an estimated drug-level chart to your GLP-1 medication page. Read the boundary below before turning it on — the chart is a learning aid, never a measurement, never medical advice.", - "whatItIsHeader": "What this is", - "whatItIs": "A sawtooth curve simulating how your logged doses superimpose over time, using the one-compartment Bateman model with absorption and half-life parameters published in the EMA EPAR for each approved GLP-1 receptor agonist. The vertical axis is unit-less and unlabeled. The shape, not the number, is what the chart shows.", - "whatItIsntHeader": "What this isn't", - "whatItIsnt": "Not a blood level. Not a measurement. Not a forecast of how your body actually responds. Not a basis for any dose, timing, or escalation decision. Talk to your prescribing clinician for anything that affects how you take your medication.", - "whyEstimateHeader": "Why it's an estimate", - "whyEstimate": "Population pharmacokinetics describe the typical patient. Individual variation is large — tirzepatide's Schneck/Urva 2024 model reports ~22% inter-individual variability on absorption and ~49% on volume of distribution. A textbook curve cannot capture your weight, kidney function, injection technique, or timing accuracy. Read the curve as the rhythm of dosing, not as your level.", - "mdrBoundaryHeader": "Regulatory boundary", - "mdrBoundary": "HealthLog displays public regulatory data and your own log entries. It does not predict, diagnose, treat, or recommend any therapy. EU Regulation 2017/745 (the Medical Device Regulation, MDR) and guidance document MDCG 2021-24 draw the line at software that interprets data for medical purposes — this chart stays decisively on the educational side of that line. If you ever feel it's nudging you toward a clinical decision, close it.", - "citationsHeader": "Sources", - "citations": "EMA European Public Assessment Reports for Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta, and Bydureon (Section 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).", - "versionLine": "Disclaimer version: {version}", - "acknowledgeCta": "I understand — enable", - "cancelCta": "Cancel", - "successToast": "Research Mode enabled. The drug-level chart is now visible on your GLP-1 medication page.", - "errorToast": "Could not enable Research Mode. Please try again.", - "staleVersionToast": "The disclaimer was updated since this page loaded. Reload to read the current version." - }, "chart": { "title": "Estimated drug level", "axisLabel": "Estimated level (relative)", @@ -4591,19 +4571,6 @@ "remainingMany": "{count} more readings to go." }, "settings": { - "researchMode": { - "sectionTitle": "Research Mode", - "subtitle": "Opt-in curve of the estimated GLP-1 drug level, from EMA-published data.", - "toggleLabel": "Show the estimated drug-level chart", - "disabledStatus": "Disabled. The drug-level chart is hidden.", - "enabledStaleStatus": "The disclaimer was updated. Re-acknowledge below to bring the chart back.", - "acknowledgedOn": "Acknowledged on {date}.", - "rePromptTitle": "Disclaimer updated", - "rePromptBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and re-confirm to keep the chart visible.", - "rePromptCta": "Re-acknowledge disclaimer", - "disableError": "Could not disable Research Mode. Please try again.", - "subtitleDetail": "Reading-only — never a measurement or medical advice." - }, "shell": { "sectionsNav": "Settings sections", "groups": { @@ -4887,8 +4854,8 @@ }, "advanced": { "title": "Advanced", - "description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record.", - "subtitle": "Research mode, data reset, and account deletion." + "description": "The way out: erase every health record for good, or close the account entirely.", + "subtitle": "Data reset and account deletion." }, "export": { "title": "Export & Import", @@ -6277,9 +6244,8 @@ }, "posture": { "title": "Privacy posture", - "description": "Control how much your data is shared with AI features and opt-in research.", - "openAi": "AI privacy mode", - "openResearch": "Research mode" + "description": "Control how much of your data is shared with AI features.", + "openAi": "AI privacy mode" } }, "mcp": { diff --git a/messages/es.json b/messages/es.json index 54791c8d1..ab4264cb7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1604,26 +1604,6 @@ } }, "researchMode": { - "dialog": { - "title": "Activar el modo Investigación", - "intro": "El modo Investigación añade un gráfico estimado del nivel de fármaco a tu página de GLP-1. Lee el límite a continuación antes de activarlo: el gráfico es una ayuda de aprendizaje, nunca una medición, nunca un consejo médico.", - "whatItIsHeader": "Qué es esto", - "whatItIs": "Una curva en diente de sierra que simula la superposición de tus dosis registradas a lo largo del tiempo, usando el modelo de Bateman de un compartimento con los parámetros de absorción y semivida publicados en el EPAR de la EMA de cada agonista GLP-1 aprobado. El eje vertical es adimensional y sin etiquetas. La forma, no el número, es lo que muestra el gráfico.", - "whatItIsntHeader": "Qué no es", - "whatItIsnt": "No es un nivel en sangre. No es una medición. No es una predicción de cómo responde realmente tu cuerpo. No es base para ninguna decisión de dosis, tiempo o escalado. Para cualquier cosa que afecte cómo tomas tu medicación, habla con tu médico prescriptor.", - "whyEstimateHeader": "Por qué es una estimación", - "whyEstimate": "La farmacocinética poblacional describe al paciente típico. La variación individual es grande: el modelo Schneck/Urva 2024 de tirzepatida reporta aproximadamente un 22 % de variabilidad interindividual en la absorción y aproximadamente un 49 % en el volumen de distribución. Una curva de manual no puede capturar tu peso, tu función renal, tu técnica de inyección ni la precisión de tus horarios. Lee la curva como el ritmo de tu dosificación, no como tu nivel.", - "mdrBoundaryHeader": "Límite regulatorio", - "mdrBoundary": "HealthLog muestra datos regulatorios públicos y tus propios registros. La aplicación no predice, no diagnostica, no trata y no recomienda ninguna terapia. El Reglamento UE 2017/745 (Medical Device Regulation, MDR) y el documento de orientación MDCG 2021-24 trazan la línea con el software que interpreta datos con fines médicos: este gráfico se queda decididamente del lado educativo de esa línea. Si en algún momento sientes que te empuja hacia una decisión clínica, ciérralo.", - "citationsHeader": "Fuentes", - "citations": "European Public Assessment Reports de la EMA para Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta y Bydureon (Sección 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).", - "versionLine": "Versión del disclaimer: {version}", - "acknowledgeCta": "Entiendo — activar", - "cancelCta": "Cancelar", - "successToast": "Modo Investigación activado. La curva de nivel de fármaco es ahora visible en tu página de GLP-1.", - "errorToast": "No se pudo activar el modo Investigación. Inténtalo de nuevo.", - "staleVersionToast": "El disclaimer se actualizó desde que cargó esta página. Recarga para leer la versión actual." - }, "chart": { "title": "Nivel de fármaco estimado", "axisLabel": "Nivel estimado (relativo)", @@ -4591,19 +4571,6 @@ "remainingMany": "Faltan {count} mediciones para la tendencia." }, "settings": { - "researchMode": { - "sectionTitle": "Modo Investigación", - "subtitle": "Curva opcional del nivel estimado del fármaco GLP-1, según datos de la EMA.", - "toggleLabel": "Mostrar la curva estimada del nivel de fármaco", - "disabledStatus": "Desactivado. La curva está oculta.", - "enabledStaleStatus": "El disclaimer se actualizó. Reconfirma debajo para volver a mostrar la curva.", - "acknowledgedOn": "Confirmado el {date}.", - "rePromptTitle": "Disclaimer actualizado", - "rePromptBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo y reconfirma para mantener la curva visible.", - "rePromptCta": "Reconfirmar disclaimer", - "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo.", - "subtitleDetail": "Solo lectura: nunca una medición ni un consejo médico." - }, "shell": { "sectionsNav": "Secciones de ajustes", "groups": { @@ -4887,8 +4854,8 @@ }, "advanced": { "title": "Avanzado", - "description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud.", - "subtitle": "Modo investigación, restablecer datos y eliminar cuenta." + "description": "La salida: borra todos los registros de salud para siempre o cierra la cuenta.", + "subtitle": "Restablecer datos y eliminar cuenta." }, "export": { "title": "Exportar e importar", @@ -6277,9 +6244,8 @@ }, "posture": { "title": "Configuración de privacidad", - "description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.", - "openAi": "Modo de privacidad de IA", - "openResearch": "Modo investigación" + "description": "Controla cuántos de tus datos se comparten con las funciones de IA.", + "openAi": "Modo de privacidad de IA" } }, "mcp": { diff --git a/messages/fr.json b/messages/fr.json index 5bebe5ce5..364b9a907 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1604,26 +1604,6 @@ } }, "researchMode": { - "dialog": { - "title": "Activer le mode Recherche", - "intro": "Le mode Recherche ajoute un graphique estimé du niveau de médicament à votre page GLP-1. Lisez la limite ci-dessous avant de l'activer — le graphique est un outil d'apprentissage, jamais une mesure, jamais un conseil médical.", - "whatItIsHeader": "Ce que c'est", - "whatItIs": "Une courbe en dents de scie qui simule la superposition de vos doses enregistrées dans le temps, à partir du modèle Bateman à un compartiment avec les paramètres d'absorption et de demi-vie publiés dans l'EPAR EMA de chaque agoniste GLP-1 approuvé. L'axe vertical est sans unité et non étiqueté. C'est la forme, pas la valeur, qui est l'information de la courbe.", - "whatItIsntHeader": "Ce que ce n'est pas", - "whatItIsnt": "Pas un taux sanguin. Pas une mesure. Pas une prévision de la réponse réelle de votre corps. Pas une base pour une décision de dose, de timing ou d'escalade. Pour tout ce qui touche à votre traitement, parlez-en à votre médecin prescripteur.", - "whyEstimateHeader": "Pourquoi c'est une estimation", - "whyEstimate": "La pharmacocinétique de population décrit le patient typique. La variabilité individuelle est grande — le modèle Schneck/Urva 2024 du tirzépatide rapporte environ 22 % de variabilité interindividuelle sur l'absorption et environ 49 % sur le volume de distribution. Une courbe de manuel ne peut capturer ni votre poids, ni votre fonction rénale, ni votre technique d'injection, ni la précision de vos horaires. Lisez la courbe comme le rythme de votre dosage, pas comme votre niveau.", - "mdrBoundaryHeader": "Limite réglementaire", - "mdrBoundary": "HealthLog affiche des données réglementaires publiques et vos propres enregistrements. L'application ne prédit, ne diagnostique, ne traite et ne recommande aucune thérapie. Le règlement UE 2017/745 (Medical Device Regulation, MDR) et le document de guidance MDCG 2021-24 tracent la limite à partir des logiciels qui interprètent des données à des fins médicales — cette courbe reste résolument du côté éducatif de cette limite. Si vous avez le sentiment qu'elle vous pousse vers une décision clinique, fermez-la.", - "citationsHeader": "Sources", - "citations": "Rapports européens publics d'évaluation (EPAR) de l'EMA pour Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta et Bydureon (section 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).", - "versionLine": "Version du disclaimer : {version}", - "acknowledgeCta": "J'ai compris — activer", - "cancelCta": "Annuler", - "successToast": "Mode Recherche activé. La courbe de niveau de médicament est maintenant visible sur votre page GLP-1.", - "errorToast": "Impossible d'activer le mode Recherche. Veuillez réessayer.", - "staleVersionToast": "Le disclaimer a été mis à jour depuis le chargement de cette page. Rechargez pour lire la version actuelle." - }, "chart": { "title": "Niveau de médicament estimé", "axisLabel": "Niveau estimé (relatif)", @@ -4591,19 +4571,6 @@ "remainingMany": "Encore {count} mesures avant la tendance." }, "settings": { - "researchMode": { - "sectionTitle": "Mode Recherche", - "subtitle": "Courbe optionnelle du niveau estimé de GLP-1, d'après les données de l'EMA.", - "toggleLabel": "Afficher la courbe estimée du niveau de médicament", - "disabledStatus": "Désactivé. La courbe est masquée.", - "enabledStaleStatus": "Le disclaimer a été mis à jour. Reconfirmez ci-dessous pour réafficher la courbe.", - "acknowledgedOn": "Confirmé le {date}.", - "rePromptTitle": "Disclaimer mis à jour", - "rePromptBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le et reconfirmez pour garder la courbe visible.", - "rePromptCta": "Reconfirmer le disclaimer", - "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer.", - "subtitleDetail": "Lecture seule — jamais une mesure ni un conseil médical." - }, "shell": { "sectionsNav": "Sections des réglages", "groups": { @@ -4887,8 +4854,8 @@ }, "advanced": { "title": "Avancé", - "description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé.", - "subtitle": "Mode recherche, réinitialisation des données et suppression du compte." + "description": "La sortie : effacer définitivement toutes les données de santé ou fermer le compte.", + "subtitle": "Réinitialisation des données et suppression du compte." }, "export": { "title": "Export et import", @@ -6277,9 +6244,8 @@ }, "posture": { "title": "Posture de confidentialité", - "description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.", - "openAi": "Mode confidentialité IA", - "openResearch": "Mode recherche" + "description": "Contrôlez la quantité de données partagées avec les fonctions d'IA.", + "openAi": "Mode confidentialité IA" } }, "mcp": { diff --git a/messages/it.json b/messages/it.json index a453e7c92..c377574f4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1604,26 +1604,6 @@ } }, "researchMode": { - "dialog": { - "title": "Attiva la modalità Ricerca", - "intro": "La modalità Ricerca aggiunge un grafico stimato del livello del farmaco alla tua pagina GLP-1. Leggi il limite qui sotto prima di attivarla: il grafico è uno strumento di apprendimento, mai una misura, mai un consiglio medico.", - "whatItIsHeader": "Cosa è", - "whatItIs": "Una curva a dente di sega che simula la sovrapposizione delle dosi registrate nel tempo, usando il modello Bateman a un compartimento con i parametri di assorbimento e di emivita pubblicati nell'EPAR EMA di ogni agonista GLP-1 approvato. L'asse verticale è privo di unità ed etichette. La forma, non il numero, è ciò che il grafico mostra.", - "whatItIsntHeader": "Cosa non è", - "whatItIsnt": "Non è un livello ematico. Non è una misura. Non è una previsione della risposta reale del tuo corpo. Non è una base per alcuna decisione di dose, di tempistica o di escalation. Per qualunque cosa riguardi come assumi il tuo farmaco, parla con il medico che te lo prescrive.", - "whyEstimateHeader": "Perché è una stima", - "whyEstimate": "La farmacocinetica di popolazione descrive il paziente tipico. La variabilità individuale è ampia: il modello Schneck/Urva 2024 del tirzepatide riporta circa il 22 % di variabilità interindividuale sull'assorbimento e circa il 49 % sul volume di distribuzione. Una curva da manuale non può catturare il tuo peso, la tua funzione renale, la tua tecnica di iniezione o la precisione degli orari. Leggi la curva come il ritmo del tuo dosaggio, non come il tuo livello.", - "mdrBoundaryHeader": "Limite regolatorio", - "mdrBoundary": "HealthLog mostra dati regolatori pubblici e le tue registrazioni. L'app non predice, non diagnostica, non tratta e non raccomanda alcuna terapia. Il Regolamento UE 2017/745 (Medical Device Regulation, MDR) e il documento di orientamento MDCG 2021-24 tracciano la linea sul software che interpreta dati per scopi medici: questa curva rimane decisamente sul lato educativo di quella linea. Se in qualunque momento ti sembra spingere verso una decisione clinica, chiudila.", - "citationsHeader": "Fonti", - "citations": "European Public Assessment Reports EMA per Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta e Bydureon (Sezione 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).", - "versionLine": "Versione del disclaimer: {version}", - "acknowledgeCta": "Ho capito — attiva", - "cancelCta": "Annulla", - "successToast": "Modalità Ricerca attivata. La curva del livello del farmaco è ora visibile sulla tua pagina GLP-1.", - "errorToast": "Impossibile attivare la modalità Ricerca. Riprova.", - "staleVersionToast": "Il disclaimer è stato aggiornato dal caricamento di questa pagina. Ricarica per leggere la versione attuale." - }, "chart": { "title": "Livello stimato del farmaco", "axisLabel": "Livello stimato (relativo)", @@ -4591,19 +4571,6 @@ "remainingMany": "Mancano {count} misurazioni al trend." }, "settings": { - "researchMode": { - "sectionTitle": "Modalità Ricerca", - "subtitle": "Curva opzionale del livello stimato di GLP-1, da dati pubblicati dall'EMA.", - "toggleLabel": "Mostra la curva stimata del livello del farmaco", - "disabledStatus": "Disattivata. La curva è nascosta.", - "enabledStaleStatus": "Il disclaimer è stato aggiornato. Riconferma sotto per rivedere la curva.", - "acknowledgedOn": "Confermato il {date}.", - "rePromptTitle": "Disclaimer aggiornato", - "rePromptBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo e riconferma per mantenere visibile la curva.", - "rePromptCta": "Riconferma disclaimer", - "disableError": "Impossibile disattivare la modalità Ricerca. Riprova.", - "subtitleDetail": "Solo lettura: mai una misurazione né un consiglio medico." - }, "shell": { "sectionsNav": "Sezioni delle impostazioni", "groups": { @@ -4887,8 +4854,8 @@ }, "advanced": { "title": "Avanzate", - "description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute.", - "subtitle": "Modalità ricerca, ripristino dati ed eliminazione account." + "description": "La via d'uscita: cancella per sempre tutti i dati di salute o chiudi l'account.", + "subtitle": "Ripristino dati ed eliminazione account." }, "export": { "title": "Esporta e importa", @@ -6277,9 +6244,8 @@ }, "posture": { "title": "Impostazione privacy", - "description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.", - "openAi": "Modalità privacy IA", - "openResearch": "Modalità ricerca" + "description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA.", + "openAi": "Modalità privacy IA" } }, "mcp": { diff --git a/messages/pl.json b/messages/pl.json index 5027504c1..c3c69948c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1604,26 +1604,6 @@ } }, "researchMode": { - "dialog": { - "title": "Włącz tryb Badawczy", - "intro": "Tryb Badawczy dodaje szacowany wykres poziomu leku do twojej strony GLP-1. Przed jego włączeniem przeczytaj granicę poniżej — wykres jest pomocą edukacyjną, nigdy pomiarem i nigdy poradą medyczną.", - "whatItIsHeader": "Czym to jest", - "whatItIs": "Piłokształtną krzywą, która symuluje nakładanie się twoich zarejestrowanych dawek w czasie, na podstawie jednokompartmentowego modelu Batemana z parametrami absorpcji i okresu półtrwania opublikowanymi w EPAR EMA dla każdego zatwierdzonego agonisty GLP-1. Oś pionowa jest bezjednostkowa i nieopisana. To kształt, a nie liczba, jest tym, co pokazuje wykres.", - "whatItIsntHeader": "Czym to nie jest", - "whatItIsnt": "Nie jest poziomem we krwi. Nie jest pomiarem. Nie jest prognozą faktycznej reakcji twojego organizmu. Nie jest podstawą żadnej decyzji o dawce, czasie ani eskalacji. We wszystkim, co dotyczy sposobu przyjmowania leku, porozmawiaj z lekarzem prowadzącym.", - "whyEstimateHeader": "Dlaczego to oszacowanie", - "whyEstimate": "Farmakokinetyka populacyjna opisuje typowego pacjenta. Zmienność osobnicza jest duża — model Schneck/Urva 2024 dla tirzepatydu raportuje około 22 % międzyosobniczej zmienności absorpcji i około 49 % objętości dystrybucji. Krzywa z podręcznika nie uchwyci ani twojej masy ciała, ani funkcji nerek, ani techniki iniekcji, ani precyzji czasu. Czytaj krzywą jako rytm dawkowania, a nie twój poziom.", - "mdrBoundaryHeader": "Granica regulacyjna", - "mdrBoundary": "HealthLog wyświetla publiczne dane regulacyjne i twoje własne wpisy. Aplikacja nie prognozuje, nie diagnozuje, nie leczy i nie rekomenduje żadnej terapii. Rozporządzenie UE 2017/745 (Medical Device Regulation, MDR) oraz dokument MDCG 2021-24 wyznaczają granicę przy oprogramowaniu interpretującym dane do celów medycznych — ten wykres pozostaje zdecydowanie po edukacyjnej stronie tej granicy. Jeśli kiedykolwiek poczujesz, że kieruje cię w stronę decyzji klinicznej, zamknij go.", - "citationsHeader": "Źródła", - "citations": "European Public Assessment Reports EMA dla Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta i Bydureon (Sekcja 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).", - "versionLine": "Wersja disclaimera: {version}", - "acknowledgeCta": "Rozumiem — włącz", - "cancelCta": "Anuluj", - "successToast": "Tryb Badawczy włączony. Krzywa poziomu leku jest teraz widoczna na twojej stronie GLP-1.", - "errorToast": "Nie udało się włączyć trybu Badawczego. Spróbuj ponownie.", - "staleVersionToast": "Disclaimer został zaktualizowany od czasu wczytania tej strony. Odśwież, aby przeczytać aktualną wersję." - }, "chart": { "title": "Szacowany poziom leku", "axisLabel": "Szacowany poziom (względny)", @@ -4591,19 +4571,6 @@ "remainingMany": "Jeszcze {count} pomiarów do trendu." }, "settings": { - "researchMode": { - "sectionTitle": "Tryb Badawczy", - "subtitle": "Opcjonalna krzywa szacowanego poziomu leku GLP-1 wg danych EMA.", - "toggleLabel": "Pokaż szacowaną krzywą poziomu leku", - "disabledStatus": "Wyłączone. Krzywa jest ukryta.", - "enabledStaleStatus": "Disclaimer został zaktualizowany. Potwierdź ponownie poniżej, aby przywrócić krzywą.", - "acknowledgedOn": "Potwierdzono {date}.", - "rePromptTitle": "Zaktualizowano disclaimer", - "rePromptBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie i potwierdź, aby krzywa pozostała widoczna.", - "rePromptCta": "Potwierdź ponownie disclaimer", - "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie.", - "subtitleDetail": "Tylko do odczytu — nigdy pomiar ani porada medyczna." - }, "shell": { "sectionsNav": "Sekcje ustawień", "groups": { @@ -4887,8 +4854,8 @@ }, "advanced": { "title": "Zaawansowane", - "description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych.", - "subtitle": "Tryb badawczy, reset danych i usunięcie konta." + "description": "Wyjście awaryjne: trwale usuń wszystkie dane zdrowotne albo zamknij konto.", + "subtitle": "Reset danych i usunięcie konta." }, "export": { "title": "Eksport i import", @@ -6277,9 +6244,8 @@ }, "posture": { "title": "Ustawienia prywatności", - "description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.", - "openAi": "Tryb prywatności AI", - "openResearch": "Tryb badawczy" + "description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI.", + "openAi": "Tryb prywatności AI" } }, "mcp": { diff --git a/prisma/migrations/0321_drop_research_mode_columns/migration.sql b/prisma/migrations/0321_drop_research_mode_columns/migration.sql new file mode 100644 index 000000000..b0451ca13 --- /dev/null +++ b/prisma/migrations/0321_drop_research_mode_columns/migration.sql @@ -0,0 +1,8 @@ +-- Research Mode is gone: the estimated GLP-1 drug-level curve is part of the +-- medication page for everyone, so the opt-in flag and the two acknowledgment +-- stamps it kept have no reader and no writer left. The endpoint, the +-- acknowledgment dialog and the Settings switch went with them. +ALTER TABLE "users" + DROP COLUMN IF EXISTS "research_mode_enabled", + DROP COLUMN IF EXISTS "research_mode_acknowledged_at", + DROP COLUMN IF EXISTS "research_mode_acknowledged_version"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3116419c1..f3f292071 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -427,9 +427,8 @@ model User { // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown // once at the start of onboarding: HealthLog is a private tracking tool, // not a clinical assessment or diagnosis; if worried, talk to a doctor. - // Mirrors the `researchModeAcknowledged*` pair — the version pins exactly - // which copy the user read, so a future material wording change can ask - // again. Null = never acknowledged (legacy accounts read as never-asked; + // The version pins exactly which copy the user read, so a future material + // wording change can ask again. Null = never acknowledged (legacy accounts read as never-asked; // the prompt only gates fresh onboarding, so they are not re-walled). The // acknowledged legal text stays reachable on the public privacy page. disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at") @@ -496,28 +495,6 @@ model User { // work. sourcePriorityJson Json? @map("source_priority_json") - // v1.4.25 W19c — Research Mode acknowledgment for the opt-in GLP-1 - // PK research-view chart. The chart is hidden behind a per-user - // flag that is set to `true` only after the user has dismissed an - // undismissable MDR-disclaimer dialog. The `researchModeAcknowledged*` - // pair records exactly *which version* of the disclaimer copy the - // user has read; when the disclaimer copy changes (e.g. EMA guidance - // update, new drug added), the Settings surface re-prompts the user - // and refuses to surface the chart until the new version is - // acknowledged. Keeping the version on the user row (instead of an - // append-only audit table) is intentional — the Coach refusal layer - // and the chart-render guard both need a cheap synchronous read. - // - // `researchModeEnabled = false` is the safe default — the chart - // never renders without an explicit opt-in. The DELETE branch of - // the API endpoint sets all three columns back to NULL/false so a - // user who turns Research Mode off must re-acknowledge the - // disclaimer if they ever turn it back on, even if the version - // hasn't changed in the interim. - researchModeEnabled Boolean @default(false) @map("research_mode_enabled") - researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at") - researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version") - // v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known // synchronization checkpoint per user. iOS reads it via // `GET /api/sync/state` to decide whether to drain its local diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 8d798ce6a..504c2b256 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -111,13 +111,6 @@ const REGISTRY: DestructiveEntry[] = [ "every health record on the account, or the whole account including passkeys and audit history", recovery: "permanent", confirm: ["AlertDialog"], - unconfirmed: [ - { - control: "research-mode switch, off position", - reason: - "Turning research mode off drops the stored acknowledgement receipt, which is regenerated by turning it back on and re-acknowledging. Nothing a person authored or measured is involved, so a dialog on a labelled switch would gate a preference rather than a loss.", - }, - ], }, { file: "components/settings/security-sessions-card.tsx", diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index e1da21d22..3114e8a16 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -230,9 +230,6 @@ const PINNED_AFFORDANCES: Record< "src/components/medications/intake-history-list-v2.tsx": { "text-success": 2, }, - "src/components/medications/research-mode-acknowledgment-dialog.tsx": { - "toast.success": 1, - }, "src/components/medications/scheduling/schedule-history-timeline.tsx": { "toast.success": 2, }, @@ -286,7 +283,7 @@ const PINNED_AFFORDANCES: Record< "src/components/settings/account-section/avatar-section.tsx": { "text-success": 1, }, - "src/components/settings/account-section/index.tsx": { "text-success": 2 }, + "src/components/settings/account-section/index.tsx": { "text-success": 1 }, "src/components/settings/advanced-section.tsx": { "text-success": 2 }, "src/components/settings/ai/ai-insights-card.tsx": { "text-success": 1 }, "src/components/settings/ai/anthropic-provider-form.tsx": { @@ -341,6 +338,9 @@ const PINNED_AFFORDANCES: Record< "text-success": 1, }, "src/components/settings/ntfy-card.tsx": { "text-success": 1 }, + // The one that used to sit inside `account-section/index.tsx` — the card + // moved to Settings → Security, the line moved with it. + "src/components/settings/password-card.tsx": { "text-success": 1 }, "src/components/settings/security-sessions-card.tsx": { "text-success": 1 }, "src/components/settings/share-link-create-form.tsx": { "text-success": 3 }, "src/components/settings/sharing-section.tsx": { "text-success": 1 }, diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts index 71e2cbe9d..8042a65c6 100644 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts @@ -227,7 +227,7 @@ describe("PATCH /api/auth/me/disable-coach", () => { it("is idempotent when the column already holds the requested value", async () => { // Toggling a column from true→true (or false→false) still writes - // through so the audit row exists. Matches the research-mode + // through so the audit row exists. Matches the doctor-report-prefs // route's "always write" posture. vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/app/api/auth/me/research-mode/__tests__/route.test.ts b/src/app/api/auth/me/research-mode/__tests__/route.test.ts deleted file mode 100644 index 705075cce..000000000 --- a/src/app/api/auth/me/research-mode/__tests__/route.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 4, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { DELETE, GET, POST } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function mkPost(body: unknown): Request { - return new Request("http://localhost/api/auth/me/research-mode", { - method: "POST", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -function mkDelete(): Request { - return new Request("http://localhost/api/auth/me/research-mode", { - method: "DELETE", - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("GET /api/auth/me/research-mode", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/research-mode"), - ); - expect(res.status).toBe(401); - expect(prisma.user.findUnique).not.toHaveBeenCalled(); - }); - - it("returns the disabled default for a fresh user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, - } as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/research-mode"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { - enabled: boolean; - acknowledgedAt: string | null; - acknowledgedVersion: string | null; - currentDisclaimerVersion: string; - }; - }; - expect(env.data.enabled).toBe(false); - expect(env.data.acknowledgedAt).toBeNull(); - expect(env.data.acknowledgedVersion).toBeNull(); - expect(env.data.currentDisclaimerVersion).toBe( - RESEARCH_MODE_DISCLAIMER_VERSION, - ); - }); - - it("returns the enabled state with the persisted version", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const ackAt = new Date("2026-05-14T10:00:00Z"); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - researchModeEnabled: true, - researchModeAcknowledgedAt: ackAt, - researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, - } as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/research-mode"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { - enabled: boolean; - acknowledgedAt: string; - acknowledgedVersion: string; - currentDisclaimerVersion: string; - }; - }; - expect(env.data.enabled).toBe(true); - expect(env.data.acknowledgedAt).toBe(ackAt.toISOString()); - expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION); - }); -}); - -describe("POST /api/auth/me/research-mode", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (POST as (r: Request) => Promise)( - mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }), - ); - expect(res.status).toBe(401); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("enables Research Mode and writes the audit log on a valid ack", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (POST as (r: Request) => Promise)( - mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { enabled: boolean; acknowledgedVersion: string }; - }; - expect(env.data.enabled).toBe(true); - expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION); - - expect(prisma.user.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: "user-1" }, - data: expect.objectContaining({ - researchModeEnabled: true, - researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, - }), - }), - ); - - expect(auditLog).toHaveBeenCalledWith( - "user.research-mode.enable", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ - next: expect.objectContaining({ - researchModeEnabled: true, - researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, - }), - }), - }), - ); - }); - - it("rejects a stale disclaimer version with 400", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (POST as (r: Request) => Promise)( - mkPost({ acknowledged: true, version: "1970-01-01.0" }), - ); - expect(res.status).toBe(400); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(auditLog).not.toHaveBeenCalled(); - }); - - it("rejects a body without acknowledged=true with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (POST as (r: Request) => Promise)( - mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("returns 429 when the per-user rate-limit fires", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 30_000, - }); - - const res = await (POST as (r: Request) => Promise)( - mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }), - ); - expect(res.status).toBe(429); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); - -describe("DELETE /api/auth/me/research-mode", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (DELETE as (r: Request) => Promise)(mkDelete()); - expect(res.status).toBe(401); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("disables Research Mode and clears the acknowledgment", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - researchModeEnabled: true, - researchModeAcknowledgedAt: new Date(), - researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (DELETE as (r: Request) => Promise)(mkDelete()); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { - enabled: boolean; - acknowledgedAt: string | null; - acknowledgedVersion: string | null; - }; - }; - expect(env.data.enabled).toBe(false); - expect(env.data.acknowledgedAt).toBeNull(); - expect(env.data.acknowledgedVersion).toBeNull(); - - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, - }, - }); - - expect(auditLog).toHaveBeenCalledWith( - "user.research-mode.disable", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ - next: { - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, - }, - }), - }), - ); - }); - - it("is idempotent when Research Mode is already off", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (DELETE as (r: Request) => Promise)(mkDelete()); - expect(res.status).toBe(200); - // The route writes the canonical off-state even when the - // column is already off — guarantees audit trail symmetry. - expect(prisma.user.update).toHaveBeenCalledTimes(1); - expect(auditLog).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/api/auth/me/research-mode/route.ts b/src/app/api/auth/me/research-mode/route.ts deleted file mode 100644 index 24053a640..000000000 --- a/src/app/api/auth/me/research-mode/route.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * v1.4.25 W19c — Research Mode acknowledgment endpoint. - * - * GET /api/auth/me/research-mode — returns the current flag, - * the timestamp + version of - * the last acknowledgment, and - * the live disclaimer version - * so the Settings UI can - * compare and re-prompt. - * POST /api/auth/me/research-mode — body { acknowledged: true, - * version }. Flips the flag - * on. Rejects 400 if `version` - * is not the live version. - * DELETE /api/auth/me/research-mode — disables Research Mode and - * clears the acknowledgment. - * Idempotent. - * - * Per-user POST rate-limit is intentionally low (5/min) — this is a - * one-tap acknowledgment dialog, not a hot path. Anything above that - * traffic looks like a malformed client retry-loop or an exploit. - * - * Prompt-injection surface — the API accepts the disclaimer - * `version` string and nothing else from the client. The version - * value never reaches the LLM; it is matched against the - * server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then - * persisted as the stamp on the user row. No free-text body. - * - * Audit-log entries fire on every state-changing call (POST, DELETE) - * with the previous + next state, mirroring the doctor-report-prefs - * route's reconcile pattern (W10 security M-3). - */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { prisma } from "@/lib/db"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk"; - -export const dynamic = "force-dynamic"; - -const POST_RATE_LIMIT = 5; -const POST_WINDOW_MS = 60_000; - -type ResearchModeResponse = { - enabled: boolean; - acknowledgedAt: string | null; - acknowledgedVersion: string | null; - currentDisclaimerVersion: string; -}; - -function shape(row: { - researchModeEnabled: boolean; - researchModeAcknowledgedAt: Date | null; - researchModeAcknowledgedVersion: string | null; -}): ResearchModeResponse { - return { - enabled: row.researchModeEnabled, - acknowledgedAt: row.researchModeAcknowledgedAt - ? row.researchModeAcknowledgedAt.toISOString() - : null, - acknowledgedVersion: row.researchModeAcknowledgedVersion, - currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION, - }; -} - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - annotate({ action: { name: "auth.me.research-mode.get" } }); - - const row = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - researchModeEnabled: true, - researchModeAcknowledgedAt: true, - researchModeAcknowledgedVersion: true, - }, - }); - // `requireAuth` already proved the user exists; defensive default - // here only matters for the (impossible-in-practice) race where - // the user is deleted mid-request. - return apiSuccess( - shape( - row ?? { - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, - }, - ), - ); -}); - -export const POST = apiHandler(async (req: Request) => { - const { user } = await requireAuth(); - - // Per-user rate-limit. 5/min is generous for a once-per-update - // acknowledgment dialog; anything more looks like a retry loop. - const rl = await checkRateLimit( - `research-mode:post:${user.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - const response = apiError("Too many requests", 429); - for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { - response.headers.set(k, v); - } - return response; - } - - let body: unknown; - try { - body = await req.json(); - } catch { - throw new HttpError(422, "research-mode.body.invalid_json"); - } - - // Hand-rolled validation — the body is tiny (two fields), and - // staying off Zod keeps the prompt-injection surface visibly - // simple in a code review. - if ( - !body || - typeof body !== "object" || - (body as { acknowledged?: unknown }).acknowledged !== true || - typeof (body as { version?: unknown }).version !== "string" - ) { - annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } }); - throw new HttpError(422, "research-mode.body.invalid_shape"); - } - - const submittedVersion = (body as { version: string }).version; - - // Stale-version guard — the dialog must show the *live* copy at - // the moment of acknowledgment. A stale version means the user - // is acknowledging older wording; we refuse and force the - // Settings UI to re-render with the new disclaimer. - if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) { - annotate({ - action: { name: "auth.me.research-mode.post.stale_version" }, - meta: { - submitted: submittedVersion, - current: RESEARCH_MODE_DISCLAIMER_VERSION, - }, - }); - throw new HttpError(400, "research-mode.version.stale"); - } - - const previous = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - researchModeEnabled: true, - researchModeAcknowledgedAt: true, - researchModeAcknowledgedVersion: true, - }, - }); - - const next = { - researchModeEnabled: true, - researchModeAcknowledgedAt: new Date(), - researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION, - }; - await prisma.user.update({ - where: { id: user.id }, - data: next, - }); - - await auditLog("user.research-mode.enable", { - userId: user.id, - ipAddress: getClientIp(req), - details: { - previous: previous ?? null, - next: { - ...next, - researchModeAcknowledgedAt: - next.researchModeAcknowledgedAt.toISOString(), - }, - }, - }); - - annotate({ - action: { name: "auth.me.research-mode.post" }, - meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION }, - }); - return apiSuccess(shape(next)); -}); - -export const DELETE = apiHandler(async (req: Request) => { - const { user } = await requireAuth(); - - const previous = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - researchModeEnabled: true, - researchModeAcknowledgedAt: true, - researchModeAcknowledgedVersion: true, - }, - }); - - // Idempotent — if Research Mode is already off, we still write - // the canonical "off" state so the audit row exists. - const next = { - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, - }; - await prisma.user.update({ - where: { id: user.id }, - data: next, - }); - - await auditLog("user.research-mode.disable", { - userId: user.id, - ipAddress: getClientIp(req), - details: { previous: previous ?? null, next }, - }); - - annotate({ action: { name: "auth.me.research-mode.delete" } }); - return apiSuccess(shape(next)); -}); diff --git a/src/components/medications/__tests__/log-injection-site-dialog.test.tsx b/src/components/medications/__tests__/log-injection-site-dialog.test.tsx index 7e7ba9dee..cbc5d4e08 100644 --- a/src/components/medications/__tests__/log-injection-site-dialog.test.tsx +++ b/src/components/medications/__tests__/log-injection-site-dialog.test.tsx @@ -24,7 +24,7 @@ import { I18nProvider } from "@/lib/i18n/context"; // The Radix Dialog portals at runtime, so its body never materialises in // static markup. Collapse the primitives to plain wrappers (same trick as -// the ResearchModeAcknowledgmentDialog suite) so the footer is reachable. +// the dose-history dialog suite) so the footer is reachable. vi.mock("@/components/ui/dialog", () => { const Pass = ({ children }: { children?: React.ReactNode }) => (
{children}
diff --git a/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx b/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx deleted file mode 100644 index e753af7b7..000000000 --- a/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx +++ /dev/null @@ -1,291 +0,0 @@ -/** - * v1.4.25 W19c-Frontend — Research-mode acknowledgment dialog tests. - * - * The dialog is rendered controlled by Settings (commit 3) and gates - * the GLP-1 drug-level chart (commit 2). The contract is: - * - * 1. Multi-section MDR copy renders (what-is, what-isn't, why, - * MDR boundary, citations). - * 2. Acknowledge CTA fires a `POST /api/auth/me/research-mode` with - * the exact server-supplied version string. - * 3. Cancel CTA fires `onOpenChange(false)` without an API call. - * 4. The stale-version error (server 400 with - * `research-mode.version.stale`) surfaces a localised error toast - * and keeps the dialog open. - * - * Setup mirrors the existing `coach-settings-sheet.test.tsx` pattern: - * SSR via `renderToStaticMarkup` so the Radix Dialog primitives are - * mocked to plain wrappers (Portals don't materialise under SSR), and - * the TanStack Query mutation is captured so we can drive the - * onSuccess / onError branches by hand. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { toast } from "sonner"; - -import { I18nProvider } from "@/lib/i18n/context"; - -const mutationCallbacks: { - mutationFn: ((version: string) => Promise) | null; - onSuccess: ((data: unknown) => void) | null; - onError: ((err: unknown) => void) | null; -} = { mutationFn: null, onSuccess: null, onError: null }; - -const lastMutateCall: { args: unknown[] } = { args: [] }; - -vi.mock("@tanstack/react-query", () => ({ - useMutation: (opts: { - mutationFn: (version: string) => Promise; - onSuccess?: (data: unknown) => void; - onError?: (err: unknown) => void; - }) => { - mutationCallbacks.mutationFn = opts.mutationFn; - mutationCallbacks.onSuccess = opts.onSuccess ?? null; - mutationCallbacks.onError = opts.onError ?? null; - return { - mutate: (version: string) => { - lastMutateCall.args = [version]; - }, - isPending: false, - }; - }, - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -vi.mock("sonner", () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - }, -})); - -// v1.4.27 R4 RC2 — the dialog now mounts . Its -// internals (Sheet on ` ({ - ResponsiveSheet: ({ - open, - title, - description, - footer, - children, - }: { - open: boolean; - title: React.ReactNode; - description?: React.ReactNode; - footer?: React.ReactNode; - children: React.ReactNode; - }) => - open ? ( -
-
-

{title}

- {description ? ( -

{description}

- ) : null} -
-
{children}
- {footer ? ( -
{footer}
- ) : null} -
- ) : null, -})); - -import { ResearchModeAcknowledgmentDialog } from "../research-mode-acknowledgment-dialog"; - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -beforeEach(() => { - mutationCallbacks.mutationFn = null; - mutationCallbacks.onSuccess = null; - mutationCallbacks.onError = null; - lastMutateCall.args = []; - vi.mocked(toast.success).mockClear(); - vi.mocked(toast.error).mockClear(); -}); - -describe("", () => { - it("renders nothing while `open === false`", () => { - const html = render( - {}} - currentDisclaimerVersion="2026-05-14.1" - />, - ); - expect(html).toBe(""); - }); - - it("renders the title + the five MDR-copy sections while open", () => { - const html = render( - {}} - currentDisclaimerVersion="2026-05-14.1" - />, - ); - - // Title + primary CTAs. - expect(html).toContain("Enable Research Mode"); - expect(html).toContain("I understand — enable"); - expect(html).toContain("Cancel"); - - // Each of the five sections renders its own headline. - expect(html).toContain("What this is"); - expect(html).toContain("What this isn't"); - expect(html).toContain("Why it's an estimate"); - expect(html).toContain("Regulatory boundary"); - expect(html).toContain("Sources"); - - // Cited regulatory references must appear verbatim in the - // user-facing copy (project-voice + research §11 + §12.4). - expect(html).toContain("2017/745"); - expect(html).toContain("MDCG 2021-24"); - // Peer-reviewed citation. - expect(html).toContain("10.1002/psp4.13099"); - - // Version line surfaces the live server value. - expect(html).toContain("Disclaimer version: 2026-05-14.1"); - }); - - it("acknowledge CTA invokes the mutation with the supplied version", async () => { - render( - {}} - currentDisclaimerVersion="2026-05-14.1" - />, - ); - - // SSR markup can't fire DOM events; invoke the mutation directly - // (the production component calls `mutation.mutate(version)` from - // the click handler with `currentDisclaimerVersion`). - expect(mutationCallbacks.mutationFn).not.toBeNull(); - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - headers: new Headers(), - json: async () => ({ data: { enabled: true } }), - // The apiFetch wrapper reads the envelope via `text()`. - text: async () => JSON.stringify({ data: { enabled: true } }), - }); - vi.stubGlobal("fetch", fetchMock); - - await mutationCallbacks.mutationFn?.("2026-05-14.1"); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("/api/auth/me/research-mode"); - expect(init.method).toBe("POST"); - expect(JSON.parse(init.body as string)).toEqual({ - acknowledged: true, - version: "2026-05-14.1", - }); - - vi.unstubAllGlobals(); - }); - - it("onSuccess fires the localised success toast", () => { - const onAcknowledged = vi.fn(); - const onOpenChange = vi.fn(); - render( - , - ); - - mutationCallbacks.onSuccess?.({ enabled: true }); - - expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast.success).mock.calls[0][0]).toContain( - "Research Mode enabled", - ); - expect(onAcknowledged).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it("stale-version error surfaces the localised error toast and keeps the dialog open", () => { - const onOpenChange = vi.fn(); - render( - , - ); - - mutationCallbacks.onError?.(new Error("research-mode.version.stale")); - - expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast.error).mock.calls[0][0]).toContain( - "disclaimer was updated", - ); - // The error path never closes the dialog — the caller stays put - // so the user can reload and re-read the new copy. - expect(onOpenChange).not.toHaveBeenCalled(); - }); - - it("generic error path surfaces the generic error toast", () => { - render( - {}} - currentDisclaimerVersion="2026-05-14.1" - />, - ); - - mutationCallbacks.onError?.(new Error("http-500")); - - expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast.error).mock.calls[0][0]).toContain( - "Could not enable Research Mode", - ); - }); - - it("acknowledge CTA is disabled until the server-supplied version arrives", () => { - const html = render( - {}} - currentDisclaimerVersion={null} - />, - ); - - // The disabled flag stamps as `disabled=""` on a server-rendered button. - // The acknowledge button carries `data-slot="research-mode-acknowledge"`. - const acknowledgeFragment = html.match( - /]*data-slot="research-mode-acknowledge"[^>]*>/, - )?.[0]; - expect(acknowledgeFragment).toBeDefined(); - expect(acknowledgeFragment).toMatch(/\sdisabled(="|\s|>)/); - - // The version line is hidden when null — guard against leaking - // `version: null` text into the UI. - expect(html).not.toContain("Disclaimer version:"); - }); - - it("renders German copy when locale='de'", () => { - const html = render( - {}} - currentDisclaimerVersion="2026-05-14.1" - />, - "de", - ); - expect(html).toContain("Forschungsmodus aktivieren"); - expect(html).toContain("Verstanden — aktivieren"); - expect(html).toContain("Abbrechen"); - expect(html).toContain("2017/745"); - }); -}); diff --git a/src/components/medications/research-mode-acknowledgment-dialog.tsx b/src/components/medications/research-mode-acknowledgment-dialog.tsx deleted file mode 100644 index 506cd8ca5..000000000 --- a/src/components/medications/research-mode-acknowledgment-dialog.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"use client"; - -/** - * v1.4.25 W19c-Frontend — Research-mode MDR acknowledgment dialog. - * - * Opt-in gate in front of the GLP-1 drug-level chart. The chart paints - * a unit-less Bateman PK curve over the user's logged intakes (research - * §2.3 / §9.6.7). Because a "drug-level" surface is the brightest EU - * MDR Class IIa edge in HealthLog's surface area (research §11 + §12.4), - * the chart never paints until the user has explicitly read and - * acknowledged the disclaimer below. The acknowledgment record is - * server-stamped with `RESEARCH_MODE_DISCLAIMER_VERSION`; if the copy - * changes the user re-acknowledges before the chart unhides. - * - * The dialog is rendered controlled by the Settings toggle and the - * "re-prompt" banner (commit 3 of this phase). It accepts: - * - `open` / `onOpenChange` for full external control - * - `currentDisclaimerVersion` — the server's authoritative version - * string. Acknowledgment posts THIS value, not the imported - * constant — server redeploys that bump the constant must be - * reflected without a client reload (W19c-Backend phase report). - * - `onAcknowledged()` — fires after a successful 200; the caller - * re-fetches the research-mode state and renders the toggle in - * the new state. - * - * Copy direction (project-voice, English, no AI/phase/wave mentions): - * - "What this is" — single-paragraph framing: the chart is an - * estimate from EMA-published population PK. - * - "What this isn't" — explicit boundary: not a measurement, not - * personalised, not medical advice. - * - "Why it's an estimate" — population PK + IIV; per-patient - * variation is large. - * - "MDR boundary" — cite EU 2017/745 and MDCG 2021-24 verbatim. - * - "Citations" — EMA EPAR + Schneck/Urva 2024 (ASCPT psp4.13099). - * - * Toast surface uses sonner (the project convention) so the user gets - * a transient confirmation on either success or rejection. - */ - -import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react"; - -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { apiPost } from "@/lib/api/api-fetch"; - -export interface ResearchModeAcknowledgmentDialogProps { - open: boolean; - onOpenChange: (next: boolean) => void; - /** - * The version string from `GET /api/auth/me/research-mode`. The - * server compares it byte-for-byte against the current constant; a - * stale value forces a 400 from the API which we surface as an - * error toast and keep the dialog open. Pass `null` while the - * GET is in flight; the Acknowledge CTA stays disabled. - */ - currentDisclaimerVersion: string | null; - /** - * Fires after the POST returns 200. Caller invalidates the - * `research-mode` query so the toggle reflects the new state. - */ - onAcknowledged?: () => void; -} - -export function ResearchModeAcknowledgmentDialog({ - open, - onOpenChange, - currentDisclaimerVersion, - onAcknowledged, -}: ResearchModeAcknowledgmentDialogProps) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [errorMessage, setErrorMessage] = useState(null); - - const mutation = useMutation({ - mutationFn: async (version: string) => { - // ApiError.message carries the envelope `error` string verbatim, so - // the stale-version code below still matches on `err.message`. - return ( - (await apiPost("/api/auth/me/research-mode", { - acknowledged: true, - version, - })) ?? null - ); - }, - onSuccess: () => { - setErrorMessage(null); - toast.success(t("medications.researchMode.dialog.successToast")); - queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() }); - onAcknowledged?.(); - onOpenChange(false); - }, - onError: (err: unknown) => { - // The 400 stale-version path is the only one we surface - // explicitly — every other failure mode (401, 429, 500, network) - // collapses to a generic error so the dialog stays open but the - // user knows the acknowledgment didn't persist. - const code = err instanceof Error ? err.message : "unknown"; - const isStale = code === "research-mode.version.stale"; - const message = isStale - ? t("medications.researchMode.dialog.staleVersionToast") - : t("medications.researchMode.dialog.errorToast"); - setErrorMessage(message); - toast.error(message); - }, - }); - - function handleAcknowledge() { - if (!currentDisclaimerVersion) return; - mutation.mutate(currentDisclaimerVersion); - } - - function handleCancel() { - setErrorMessage(null); - onOpenChange(false); - } - - return ( - - - {t("medications.researchMode.dialog.title")} - - } - description={t("medications.researchMode.dialog.intro")} - className="sm:max-w-xl" - footer={ - <> - - - - } - > -
-
-

- {t("medications.researchMode.dialog.whatItIsHeader")} -

-

- {t("medications.researchMode.dialog.whatItIs")} -

-
- -
-

- {t("medications.researchMode.dialog.whatItIsntHeader")} -

-

- {t("medications.researchMode.dialog.whatItIsnt")} -

-
- -
-

- {t("medications.researchMode.dialog.whyEstimateHeader")} -

-

- {t("medications.researchMode.dialog.whyEstimate")} -

-
- -
-

-

-

- {t("medications.researchMode.dialog.mdrBoundary")} -

-
- -
-

- {t("medications.researchMode.dialog.citationsHeader")} -

-

- {t("medications.researchMode.dialog.citations")} -

-
- - {currentDisclaimerVersion && ( -

- {t("medications.researchMode.dialog.versionLine", { - version: currentDisclaimerVersion, - })} -

- )} - - {errorMessage && ( -

- {errorMessage} -

- )} -
-
- ); -} diff --git a/src/components/settings/__tests__/advanced-account-delete.test.tsx b/src/components/settings/__tests__/advanced-account-delete.test.tsx index fd04efba8..fa59afecd 100644 --- a/src/components/settings/__tests__/advanced-account-delete.test.tsx +++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx @@ -29,10 +29,6 @@ vi.mock("@tanstack/react-query", () => ({ useMutation: () => ({ mutate: vi.fn(), isPending: false }), })); -vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({ - ResearchModeAcknowledgmentDialog: () =>
, -})); - import { AdvancedSection } from "../advanced-section"; function render(locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/advanced-research-mode.test.tsx b/src/components/settings/__tests__/advanced-research-mode.test.tsx deleted file mode 100644 index 2984351cc..000000000 --- a/src/components/settings/__tests__/advanced-research-mode.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * v1.4.25 W19c-Frontend — `` Research Mode card tests. - * - * The card lives next to the danger-zone delete and drives three - * server interactions: - * - * GET /api/auth/me/research-mode on mount, via TanStack Query - * POST /api/auth/me/research-mode via the acknowledgment dialog - * DELETE /api/auth/me/research-mode directly when the user - * toggles OFF - * - * The toggle ON path opens the dialog; only the dialog's CTA fires - * the POST (so the user reads the disclaimer first). OFF fires - * DELETE directly. A version-mismatch banner renders above the - * toggle whenever `acknowledgedVersion !== currentDisclaimerVersion` - * even when `enabled === true`. - * - * The dialog itself is covered by - * `research-mode-acknowledgment-dialog.test.tsx`; we mock it down to a - * marker here so the toggle behaviour is the only contract under - * test. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; - -const queryResults: Record = {}; - -function setQueryResult(keyJoined: string, data: unknown) { - queryResults[keyJoined] = data; -} - -vi.mock("@tanstack/react-query", () => ({ - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - const key = (queryKey as Array).join("/"); - return { - data: queryResults[key], - isLoading: false, - }; - }, - useQueryClient: () => ({ invalidateQueries: vi.fn() }), - useMutation: () => ({ mutate: vi.fn(), isPending: false }), -})); - -// The acknowledgment dialog is exercised in its own spec; mock the -// import to a transparent marker so we can assert "the toggle ON path -// renders the dialog with the right props" without re-running the -// dialog tree. -vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({ - ResearchModeAcknowledgmentDialog: ({ - open, - currentDisclaimerVersion, - }: { - open: boolean; - currentDisclaimerVersion: string | null; - }) => ( -
- ), -})); - -import { AdvancedSection } from "../advanced-section"; - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -beforeEach(() => { - for (const k of Object.keys(queryResults)) delete queryResults[k]; -}); - -describe(" → Research Mode card", () => { - it("renders the section title + subtitle + toggle in OFF state", () => { - setQueryResult("research-mode", { - enabled: false, - acknowledgedAt: null, - acknowledgedVersion: null, - currentDisclaimerVersion: "2026-05-14.1", - }); - - const html = render(); - - expect(html).toContain('data-slot="settings-research-mode-card"'); - expect(html).toContain("Research Mode"); - expect(html).toContain("Show the estimated drug-level chart"); - // OFF state status line. - expect(html).toContain("Disabled. The drug-level chart is hidden."); - // No re-prompt banner when versions already align (or the user has - // never acknowledged in the first place). - expect(html).not.toContain('data-slot="settings-research-mode-reprompt"'); - // Switch is unchecked. - const switchTag = html.match( - /]*data-slot="settings-research-mode-toggle"[^>]*>/, - )?.[0]; - expect(switchTag).toBeDefined(); - expect(switchTag).not.toMatch(/data-state="checked"/); - }); - - it("renders the acknowledged-on status when enabled + versions aligned", () => { - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-05-14T08:30:00Z", - acknowledgedVersion: "2026-05-14.1", - currentDisclaimerVersion: "2026-05-14.1", - }); - - const html = render(); - - // Acknowledged-on copy carries the formatted date (the i18n - // template uses `{date}`; the formatter renders the day). - expect(html).toMatch(/Acknowledged on/); - // Banner stays hidden when versions match. - expect(html).not.toContain('data-slot="settings-research-mode-reprompt"'); - // Switch is checked. - const switchTag = html.match( - /]*data-slot="settings-research-mode-toggle"[^>]*>/, - )?.[0]; - expect(switchTag).toMatch(/data-state="checked"/); - }); - - it("renders the re-prompt banner when versions diverge", () => { - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-04-01T00:00:00Z", - acknowledgedVersion: "2026-04-01.1", - currentDisclaimerVersion: "2026-05-14.1", - }); - - const html = render(); - - expect(html).toContain('data-slot="settings-research-mode-reprompt"'); - expect(html).toContain("Disclaimer updated"); - expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"'); - expect(html).toContain("Re-acknowledge disclaimer"); - // Status line reflects the stale state without claiming - // "acknowledged on …" — that copy would mis-represent that the - // chart is currently painting. - expect(html).not.toMatch(/Acknowledged on/); - expect(html).toContain( - "The disclaimer was updated. Re-acknowledge below to bring the chart back.", - ); - // The toggle stays "on" — we surface the previous choice; the - // banner explains why the chart isn't painting. - const switchTag = html.match( - /]*data-slot="settings-research-mode-toggle"[^>]*>/, - )?.[0]; - expect(switchTag).toMatch(/data-state="checked"/); - }); - - it("forwards the current server-supplied version to the dialog", () => { - setQueryResult("research-mode", { - enabled: false, - acknowledgedAt: null, - acknowledgedVersion: null, - currentDisclaimerVersion: "2026-06-01.2", - }); - - const html = render(); - - // The dialog renders even when closed (its `open` prop is what - // controls visibility); the data-version attribute lets us verify - // the parent passes the live version string through. - expect(html).toContain('data-slot="mock-ack-dialog"'); - expect(html).toMatch(/data-version="2026-06-01\.2"/); - expect(html).toMatch(/data-open="false"/); - }); - - it("renders German copy when locale='de'", () => { - setQueryResult("research-mode", { - enabled: false, - acknowledgedAt: null, - acknowledgedVersion: null, - currentDisclaimerVersion: "2026-05-14.1", - }); - - const html = render(, "de"); - - expect(html).toContain("Forschungsmodus"); - expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen"); - expect(html).toContain("Deaktiviert"); - }); -}); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 8a6ea9e80..5949a2222 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -10,7 +10,7 @@ * canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing * per-column endpoints — never the module-allowlist the gate ignores for them. * - * Test strategy mirrors `advanced-research-mode.test.tsx`: mock + * Test strategy mirrors `advanced-account-delete.test.tsx`: mock * `@tanstack/react-query` so the rendered tree executes under SSR * (`renderToStaticMarkup`), capture the `useMutation` config to assert the * PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 80d062ec0..181e6907c 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, Trash2 } from "lucide-react"; import { AlertDialog, @@ -16,19 +16,10 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Switch } from "@/components/ui/switch"; -import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog"; -import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { - type ResearchModeStatus, - researchModeGateState, -} from "@/lib/medications/research-mode-types"; import { apiFetchRaw } from "@/lib/api/api-fetch"; /** @@ -40,179 +31,28 @@ import { apiFetchRaw } from "@/lib/api/api-fetch"; * data" surface that should never live next to a single-click export * button. * - * v1.4.25 W19c-Frontend adds the Research Mode toggle on this page. - * Research Mode and the data-reset are both opt-in / version-gated - * controls; they share a semantic shelf even though they touch - * different parts of the user record. Toggle ON opens the - * acknowledgment dialog (the user reads + confirms before the server - * stamps the version). Toggle OFF fires DELETE directly. A separate - * amber banner above the toggle catches the version-mismatch case - * (server bumped the disclaimer while the user was still - * acknowledged) and surfaces a Re-acknowledge CTA that re-opens the - * dialog. + * v1.4.25 added a Research Mode opt-in here, gating the estimated GLP-1 + * drug-level curve behind an acknowledged disclaimer version. The chart + * stopped reading the flag some releases ago and has painted for everyone + * since, so the toggle governed nothing: flipping it off changed no screen. + * The curve is simply part of the medication page now, and the switch, its + * acknowledgment dialog, the endpoint behind it and the three user columns + * it stamped are gone. This page is the destructive controls. */ export function AdvancedSection() { // v1.18.6 (W9) — the visible heading + subtitle now come from the shared // `` in the route; this body is the advanced cards. // v1.18.6.1 — the guided tour replay card was removed: the tour now exists // only as the first-time auto-start after onboarding. This page is the - // research-mode + data/account destructive controls. + // data/account destructive controls. return (
-
); } -/** - * v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided - * the toggle's caption out of the JSX. The four-way ladder (loading / - * enabled-open / enabled-stale / disabled) is dense to read inline, - * and the surface stays open to a fifth state ("never queried") when - * we wire the offline shell in v1.4.26. - */ -function researchModeStatusLabel( - status: ResearchModeStatus | null | undefined, - isLoading: boolean, - t: (key: string, params?: Record) => string, -): string { - if (isLoading) return t("common.loading"); - const gate = researchModeGateState(status); - if (gate === "off") return t("settings.researchMode.disabledStatus"); - if (gate === "stale") return t("settings.researchMode.enabledStaleStatus"); - return t("settings.researchMode.acknowledgedOn", { - date: status?.acknowledgedAt ? formatDateTime(status.acknowledgedAt) : "—", - }); -} - -function ResearchModeCard() { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [dialogOpen, setDialogOpen] = useState(false); - const [toggleBusy, setToggleBusy] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - const { data: status, isLoading } = useQuery({ - queryKey: queryKeys.researchMode(), - queryFn: async () => { - const res = await apiFetchRaw("/api/auth/me/research-mode"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as ResearchModeStatus; - }, - staleTime: 60 * 1000, - }); - - const gateState = researchModeGateState(status); - const showRePrompt = gateState === "stale"; - // The Switch's `checked` mirrors the server flag exactly — even when - // the version is stale we keep the toggle "on" so the user sees that - // their previous choice is preserved; the banner above the toggle - // explains why the chart isn't painting. - const switchChecked = !!status?.enabled; - - async function handleToggle(next: boolean) { - if (toggleBusy) return; - setErrorMessage(null); - if (next) { - // Toggle ON → open the acknowledgment dialog. The dialog owns - // the POST; on success it invalidates the `research-mode` query - // and the toggle reflects the new state. - setDialogOpen(true); - return; - } - // Toggle OFF → fire DELETE directly. - setToggleBusy(true); - try { - const res = await apiFetchRaw("/api/auth/me/research-mode", { - method: "DELETE", - }); - if (!res.ok) { - setErrorMessage(t("settings.researchMode.disableError")); - return; - } - queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() }); - } catch { - setErrorMessage(t("settings.researchMode.disableError")); - } finally { - setToggleBusy(false); - } - } - - return ( - - -

{t("settings.researchMode.subtitleDetail")}

- - {showRePrompt && ( - -

{t("settings.researchMode.rePromptBody")}

- -
- )} - -
-
-

- {t("settings.researchMode.toggleLabel")} -

-

- {researchModeStatusLabel(status, isLoading, t)} -

-
- { - void handleToggle(next); - }} - aria-label={t("settings.researchMode.toggleLabel")} - data-slot="settings-research-mode-toggle" - /> -
- - {errorMessage && ( -

- {errorMessage} -

- )} - - -
- ); -} - function DataResetCard() { const { t } = useTranslations(); const queryClient = useQueryClient(); diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 5d311535b..605460b4b 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -179,7 +179,9 @@ export function PrivacySection() { - {/* Privacy posture (AI privacy mode + research mode live on their pages) */} + {/* Privacy posture. The AI privacy mode is what is left to decide here: + research mode is gone, so the second button pointed at a page that + no longer carries the control it named. */} - diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 98092efe2..3048696e0 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -376,9 +376,6 @@ export const USER_RESET = { globalExcludedInjectionSites: [], moodReminderEnabled: false, notificationPrefs: Prisma.DbNull, - researchModeEnabled: false, - researchModeAcknowledgedAt: null, - researchModeAcknowledgedVersion: null, // Back to the start of the tour — the record it was built around is gone onboardingCompletedAt: null, diff --git a/src/lib/medications/__tests__/glp1-pk.test.ts b/src/lib/medications/__tests__/glp1-pk.test.ts index ed7b6c4f3..5a9ddc104 100644 --- a/src/lib/medications/__tests__/glp1-pk.test.ts +++ b/src/lib/medications/__tests__/glp1-pk.test.ts @@ -4,11 +4,7 @@ import { GLP1_DRUG_IDS, type Glp1DrugId, } from "@/lib/medications/glp1-knowledge"; -import { - computeOneCompartment, - RESEARCH_MODE_DISCLAIMER_VERSION, - shotPhaseAt, -} from "@/lib/medications/glp1-pk"; +import { computeOneCompartment, shotPhaseAt } from "@/lib/medications/glp1-pk"; const ASOF = new Date("2026-05-14T12:00:00Z"); @@ -16,19 +12,6 @@ function hoursBefore(ms: Date, hours: number): Date { return new Date(ms.getTime() - hours * 60 * 60 * 1000); } -describe("RESEARCH_MODE_DISCLAIMER_VERSION", () => { - it("is a non-empty string", () => { - expect(typeof RESEARCH_MODE_DISCLAIMER_VERSION).toBe("string"); - expect(RESEARCH_MODE_DISCLAIMER_VERSION.length).toBeGreaterThan(0); - }); - - it("matches the YYYY-MM-DD.N version stamp shape", () => { - expect(RESEARCH_MODE_DISCLAIMER_VERSION).toMatch( - /^\d{4}-\d{2}-\d{2}\.\d+$/, - ); - }); -}); - describe("computeOneCompartment", () => { describe("per-drug smoke test", () => { // Iterate the catalog so the day a new EMA-approved drug diff --git a/src/lib/medications/__tests__/research-mode-types.test.ts b/src/lib/medications/__tests__/research-mode-types.test.ts deleted file mode 100644 index 6480e04f6..000000000 --- a/src/lib/medications/__tests__/research-mode-types.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - researchModeGateState, - type ResearchModeStatus, -} from "../research-mode-types"; - -const ACK_OPEN: ResearchModeStatus = { - enabled: true, - acknowledgedAt: "2026-05-13T00:00:00.000Z", - acknowledgedVersion: "v3", - currentDisclaimerVersion: "v3", -}; - -const ACK_STALE: ResearchModeStatus = { - enabled: true, - acknowledgedAt: "2026-04-01T00:00:00.000Z", - acknowledgedVersion: "v2", - currentDisclaimerVersion: "v3", -}; - -const OFF: ResearchModeStatus = { - enabled: false, - acknowledgedAt: null, - acknowledgedVersion: null, - currentDisclaimerVersion: "v3", -}; - -describe("researchModeGateState", () => { - it("returns 'off' when status is null (unauthenticated / loading)", () => { - expect(researchModeGateState(null)).toBe("off"); - }); - - it("returns 'off' when status is undefined", () => { - expect(researchModeGateState(undefined)).toBe("off"); - }); - - it("returns 'off' when the user never enabled Research Mode", () => { - expect(researchModeGateState(OFF)).toBe("off"); - }); - - it("returns 'open' when enabled and acknowledged versions align", () => { - expect(researchModeGateState(ACK_OPEN)).toBe("open"); - }); - - it("returns 'stale' when enabled but server bumped the disclaimer version", () => { - expect(researchModeGateState(ACK_STALE)).toBe("stale"); - }); - - it("treats acknowledgedVersion === null as stale when enabled", () => { - const status: ResearchModeStatus = { - enabled: true, - acknowledgedAt: null, - acknowledgedVersion: null, - currentDisclaimerVersion: "v3", - }; - expect(researchModeGateState(status)).toBe("stale"); - }); - - it("never returns 'open' when enabled is false even if versions happen to match", () => { - const status: ResearchModeStatus = { - enabled: false, - acknowledgedAt: "2026-05-13T00:00:00.000Z", - acknowledgedVersion: "v3", - currentDisclaimerVersion: "v3", - }; - expect(researchModeGateState(status)).toBe("off"); - }); -}); diff --git a/src/lib/medications/glp1-pk.ts b/src/lib/medications/glp1-pk.ts index fb841c878..1a7f217cb 100644 --- a/src/lib/medications/glp1-pk.ts +++ b/src/lib/medications/glp1-pk.ts @@ -66,28 +66,6 @@ import { GLP1_DRUGS, type Glp1DrugId } from "@/lib/medications/glp1-knowledge"; -/** - * Version stamp of the MDR-disclaimer copy that the Research Mode - * dialog displays. The API endpoint at - * `/api/auth/me/research-mode` records this exact string on the - * user row at acknowledgment time; the Settings UI re-prompts the - * user when the persisted value drifts behind this constant. - * - * Format: `YYYY-MM-DD.N` — date the copy was finalised, plus a - * single-digit counter so two updates on the same day stay - * distinct. - * - * BUMP THIS STRING when: - * - the disclaimer wording changes (any user-facing text edit), - * - a new drug joins the catalog (the disclaimer enumerates them), - * - the EMA EPAR cited as source changes version. - * - * Do NOT change it silently for cosmetic edits — every bump forces - * every user to re-acknowledge on next chart open, which is the - * desired behaviour but also visible friction. - */ -export const RESEARCH_MODE_DISCLAIMER_VERSION = "2026-05-14.1"; - /** * A logged GLP-1 intake event reduced to the two fields the PK * math actually needs. The caller adapts from the project's richer diff --git a/src/lib/medications/research-mode-types.ts b/src/lib/medications/research-mode-types.ts deleted file mode 100644 index e9524fd1b..000000000 --- a/src/lib/medications/research-mode-types.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * v1.4.25 W21 Fix-N — shared Research Mode status DTO. - * - * Both `` (medication detail page) and the Settings → - * Advanced surface (``) query the same - * `/api/auth/me/research-mode` endpoint to gate the drug-level chart on - * acknowledged disclaimer version. Until Fix-N both surfaces hand-rolled - * the same `ResearchModeStatus` interface; this module is now the single - * source of truth so the two readers can never disagree on shape. - * - * Contract mirrors the API response under the `data` key: - * { enabled, acknowledgedAt, acknowledgedVersion, currentDisclaimerVersion } - * - * `enabled === true && acknowledgedVersion === currentDisclaimerVersion` - * is the only gate-open shape; every other permutation (off, stale-on) - * renders the gated placeholder UI. - */ -export interface ResearchModeStatus { - /** User opt-in flag — false unless the user has acknowledged at least once. */ - enabled: boolean; - /** ISO timestamp of the most recent acknowledgment, null when never acknowledged. */ - acknowledgedAt: string | null; - /** Disclaimer version the user last acknowledged; null when never acknowledged. */ - acknowledgedVersion: string | null; - /** Disclaimer version the server is currently serving. Drives the re-prompt branch. */ - currentDisclaimerVersion: string; -} - -/** - * Tri-state helper — narrow the raw status into one of the three UI - * branches: off, stale (acknowledged-but-server-bumped-version), or - * open. The DrugLevelChart and the Settings re-prompt banner both use - * this so the rules stay aligned. - */ -export type ResearchModeGateState = "off" | "stale" | "open"; - -export function researchModeGateState( - status: ResearchModeStatus | null | undefined, -): ResearchModeGateState { - if (!status || !status.enabled) return "off"; - if (status.acknowledgedVersion !== status.currentDisclaimerVersion) { - return "stale"; - } - return "open"; -} diff --git a/src/lib/onboarding/disclaimer.ts b/src/lib/onboarding/disclaimer.ts index d4ae9dd7a..02f37ef2d 100644 --- a/src/lib/onboarding/disclaimer.ts +++ b/src/lib/onboarding/disclaimer.ts @@ -1,9 +1,8 @@ /** * v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment version. * - * Pinned per the `researchModeAcknowledged*` precedent: the user row stores - * exactly which copy was acknowledged, so a future material wording change - * can bump this constant to re-prompt. Keep it in lockstep with the + * The user row stores exactly which copy was acknowledged, so a future + * material wording change can bump this constant to re-prompt. Keep it in lockstep with the * `onboarding.disclaimer.*` i18n copy — bump on a substantive text change, * not on a typo fix. */ diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index e923c2656..edf9fadbf 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -52,7 +52,6 @@ export const settingsKeys = { /** v1.15.12 H1 — admin overview "update available" check against the * latest GitHub release tag (best-effort, day-stale). */ versionUpdateCheck: () => ["version", "update-check"] as const, - researchMode: () => ["research-mode"] as const, /** * v1.23 — the Data & Privacy dashboard's read-only retention + encryption From dc56f70961ef1d210d4382adabbc14f096e7ec9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:48:39 +0200 Subject: [PATCH 06/12] fix(insights): give an ECG recording its own page and quiet the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ECG surface opened with three lines of muted text saying what HealthLog does not do, before a single strip. Two of them said the same thing — one from the page's explainer slot, one hand-rolled beneath it — and the third repeated it under the list. The device attribution on the recording itself already carries that claim where it means something, so the three go and the page opens on the recordings. - the device's verdict rides the timestamp as a tag instead of taking a line of its own beneath it. It qualifies the recording; it is not a second fact - a recording is a route now, `/insights/ecg/[id]`, with the back link ABOVE the heading exactly like a workout detail. The detail used to swap itself into the list card with its back button under the page title, and the recording had no address at all: nothing to deep link, nothing for the browser's own Back to return to. It reads by id, so a cold link works - the list paints the five most recent strips. A watch that records every morning turns an unbounded list into a scroll, not a history `ecg-classification.ts` holds the verdict label map the list, the detail and the cross-link card each used to carry a copy of. --- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/pl.json | 5 +- src/app/insights/ecg/[id]/page.tsx | 47 +++ src/app/insights/ecg/__tests__/page.test.tsx | 16 +- src/app/insights/ecg/page.tsx | 14 +- .../insights/__tests__/ecg-detail.test.tsx | 95 +++++ .../insights/__tests__/ecg-section.test.tsx | 153 +++----- src/components/insights/ecg-detail.tsx | 160 ++++++++ src/components/insights/ecg-section.tsx | 366 +++++------------- src/lib/insights/ecg-classification.ts | 36 ++ 14 files changed, 512 insertions(+), 405 deletions(-) create mode 100644 src/app/insights/ecg/[id]/page.tsx create mode 100644 src/components/insights/__tests__/ecg-detail.test.tsx create mode 100644 src/components/insights/ecg-detail.tsx create mode 100644 src/lib/insights/ecg-classification.ts diff --git a/messages/de.json b/messages/de.json index 4b2738597..2ca8e44a9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3090,8 +3090,7 @@ "valuesBack": "Zurück zu Insights", "valuesBackToMetric": "Zurück zu {metric}", "scoresBack": "Zurück zu Insights", - "ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts.", - "ecgDisclaimer": "HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert." + "ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts." }, "units": { "steps": "Schritte", @@ -4235,10 +4234,8 @@ }, "ecg": { "sectionTitle": "EKG-Aufzeichnungen", - "sectionIntro": "Einkanal-EKG-Streifen, die dein Gerät aufgezeichnet hat. HealthLog zeigt nur die Aufzeichnung und das von deinem Gerät erstellte Ergebnis – die Kurve wird nicht gelesen oder ausgewertet.", "loadError": "Deine EKG-Aufzeichnungen konnten nicht geladen werden.", "backToList": "Zurück zu den Aufzeichnungen", - "disclaimer": "Dies ist das Ergebnis, das dein Aufzeichnungsgerät erstellt hat. HealthLog liest oder interpretiert EKG-Aufzeichnungen nicht und stellt keine Diagnose.", "clinicianNote": "Wenn dich dieses Ergebnis beunruhigt, besprich diese Aufzeichnung mit einer Ärztin oder einem Arzt.", "resultAttribution": "Aufgezeichnetes Ergebnis: {result}, wie vom Aufzeichnungsgerät gemeldet.", "result": { diff --git a/messages/en.json b/messages/en.json index de26039d7..cce8b2ea2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3090,8 +3090,7 @@ "valuesBack": "Back to insights", "valuesBackToMetric": "Back to {metric}", "scoresBack": "Back to insights", - "ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result.", - "ecgDisclaimer": "HealthLog shows the trace and the recording — it does not interpret the waveform." + "ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result." }, "units": { "steps": "steps", @@ -4235,10 +4234,8 @@ }, "ecg": { "sectionTitle": "ECG recordings", - "sectionIntro": "Single-lead ECG strips recorded by your device. HealthLog only shows the recording and the result your device produced — it does not read or interpret the waveform.", "loadError": "Could not load your ECG recordings.", "backToList": "Back to recordings", - "disclaimer": "This is the result your recording device produced. HealthLog does not read or interpret ECG recordings and does not provide a diagnosis.", "clinicianNote": "If this result concerns you, discuss this recording with a clinician.", "resultAttribution": "Recorded result: {result}, as reported by the recording device.", "result": { diff --git a/messages/es.json b/messages/es.json index ab4264cb7..14df01c07 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3090,8 +3090,7 @@ "valuesBack": "Volver a Insights", "valuesBackToMetric": "Volver a {metric}", "scoresBack": "Volver a Insights", - "ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo.", - "ecgDisclaimer": "HealthLog muestra el trazado y el registro, pero no interpreta la onda." + "ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo." }, "units": { "steps": "pasos", @@ -4235,10 +4234,8 @@ }, "ecg": { "sectionTitle": "Registros de ECG", - "sectionIntro": "Tiras de ECG de una sola derivación registradas por tu dispositivo. HealthLog solo muestra el registro y el resultado que produjo tu dispositivo; no lee ni interpreta la onda.", "loadError": "No se pudieron cargar tus registros de ECG.", "backToList": "Volver a los registros", - "disclaimer": "Este es el resultado que produjo tu dispositivo de registro. HealthLog no lee ni interpreta los registros de ECG y no proporciona un diagnóstico.", "clinicianNote": "Si este resultado te preocupa, comenta este registro con un profesional médico.", "resultAttribution": "Resultado registrado: {result}, según lo informado por el dispositivo de registro.", "result": { diff --git a/messages/fr.json b/messages/fr.json index 364b9a907..1510ead0c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3090,8 +3090,7 @@ "valuesBack": "Retour aux Insights", "valuesBackToMetric": "Retour à {metric}", "scoresBack": "Retour aux Insights", - "ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil.", - "ecgDisclaimer": "HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe." + "ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil." }, "units": { "steps": "pas", @@ -4235,10 +4234,8 @@ }, "ecg": { "sectionTitle": "Enregistrements ECG", - "sectionIntro": "Tracés ECG à une seule dérivation enregistrés par votre appareil. HealthLog affiche uniquement l'enregistrement et le résultat produit par votre appareil ; il ne lit ni n'interprète le tracé.", "loadError": "Impossible de charger vos enregistrements ECG.", "backToList": "Retour aux enregistrements", - "disclaimer": "Il s'agit du résultat produit par votre appareil d'enregistrement. HealthLog ne lit ni n'interprète les enregistrements ECG et ne fournit aucun diagnostic.", "clinicianNote": "Si ce résultat vous préoccupe, parlez de cet enregistrement à un professionnel de santé.", "resultAttribution": "Résultat enregistré : {result}, tel que signalé par l'appareil d'enregistrement.", "result": { diff --git a/messages/it.json b/messages/it.json index c377574f4..c0d97c4ef 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3090,8 +3090,7 @@ "valuesBack": "Torna a Insights", "valuesBackToMetric": "Torna a {metric}", "scoresBack": "Torna a Insights", - "ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo.", - "ecgDisclaimer": "HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda." + "ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo." }, "units": { "steps": "passi", @@ -4235,10 +4234,8 @@ }, "ecg": { "sectionTitle": "Registrazioni ECG", - "sectionIntro": "Tracciati ECG a derivazione singola registrati dal tuo dispositivo. HealthLog mostra solo la registrazione e il risultato prodotto dal dispositivo; non legge né interpreta il tracciato.", "loadError": "Impossibile caricare le tue registrazioni ECG.", "backToList": "Torna alle registrazioni", - "disclaimer": "Questo è il risultato prodotto dal tuo dispositivo di registrazione. HealthLog non legge né interpreta le registrazioni ECG e non fornisce una diagnosi.", "clinicianNote": "Se questo risultato ti preoccupa, parla di questa registrazione con un medico.", "resultAttribution": "Risultato registrato: {result}, come riportato dal dispositivo di registrazione.", "result": { diff --git a/messages/pl.json b/messages/pl.json index c3c69948c..90eb7eda8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3090,8 +3090,7 @@ "valuesBack": "Powrót do Insights", "valuesBackToMetric": "Powrót do {metric}", "scoresBack": "Powrót do Insights", - "ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia.", - "ecgDisclaimer": "HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej." + "ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia." }, "units": { "steps": "kroki", @@ -4235,10 +4234,8 @@ }, "ecg": { "sectionTitle": "Zapisy EKG", - "sectionIntro": "Jednoodprowadzeniowe zapisy EKG zarejestrowane przez Twoje urządzenie. HealthLog pokazuje tylko zapis i wynik wygenerowany przez urządzenie — nie odczytuje ani nie interpretuje przebiegu.", "loadError": "Nie udało się wczytać Twoich zapisów EKG.", "backToList": "Powrót do zapisów", - "disclaimer": "To jest wynik wygenerowany przez Twoje urządzenie rejestrujące. HealthLog nie odczytuje ani nie interpretuje zapisów EKG i nie stawia diagnozy.", "clinicianNote": "Jeśli ten wynik Cię niepokoi, omów ten zapis z lekarzem.", "resultAttribution": "Zarejestrowany wynik: {result}, zgodnie z informacją z urządzenia rejestrującego.", "result": { diff --git a/src/app/insights/ecg/[id]/page.tsx b/src/app/insights/ecg/[id]/page.tsx new file mode 100644 index 000000000..e9ba67545 --- /dev/null +++ b/src/app/insights/ecg/[id]/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { use } from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { BackLink } from "@/components/ui/back-link"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { EcgDetail } from "@/components/insights/ecg-detail"; + +/** + * `/insights/ecg/[id]` — one ECG recording. + * + * The detail used to swap itself into the list card, with its own back + * button sitting inside the card above the trace. That put the way back + * BELOW the page heading, which is the one place a back control never + * belongs, and it left the recording without an address: nothing to deep + * link, nothing for the browser's own Back to return to. + * + * It is a route now, shaped exactly like `/insights/workouts/[id]`: a + * `BackLink` above the `` title, and a body that fetches by + * id so a cold deep link works without the list. + */ +export default function InsightsEcgDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { t } = useTranslations(); + const { id } = use(params); + + return ( + + } + > + + + ); +} diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx index 8ddba695e..fec827e45 100644 --- a/src/app/insights/ecg/__tests__/page.test.tsx +++ b/src/app/insights/ecg/__tests__/page.test.tsx @@ -63,21 +63,31 @@ function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) { } describe("/insights/ecg page (H1)", () => { - it("renders the EcgSection with its disclaimer when recordings exist", () => { + it("renders the EcgSection when recordings exist", () => { const html = render({ recordings: [seededRecording()], hasRecordings: true, }); - // The reused section + its load-bearing non-diagnostic disclaimer render. expect(html).toContain('data-slot="ecg-section"'); expect(html).toContain('data-slot="ecg-card"'); - expect(html).toContain('data-slot="ecg-disclaimer"'); // The shell owns the page heading (`

`); the section's own // `` (`

`) is suppressed via `hideHeading`. expect(html).toContain('id="insights-subpage-title"'); expect(html).not.toContain(" { + // Two muted lines used to lead this page — one from the shell's own + // explainer slot and one hand-rolled beneath it, both saying HealthLog + // does not read the curve. The page opens on the recordings now. + const html = render({ + recordings: [seededRecording()], + hasRecordings: true, + }); + expect(html).not.toContain('data-slot="ecg-disclaimer"'); + expect(html).not.toContain("is not interpreted"); + }); + it("shows the empty state (no section) when the account has no recordings", () => { const html = render({ recordings: [], hasRecordings: false }); expect(html).not.toContain('data-slot="ecg-section"'); diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx index 30abbea93..eaec88fa0 100644 --- a/src/app/insights/ecg/page.tsx +++ b/src/app/insights/ecg/page.tsx @@ -22,13 +22,14 @@ import { EcgSection } from "@/components/insights/ecg-section"; * all. This page gives ECG its own address, reached from the Heart-group * pill in the tab strip (gated on `hasRecordings`) and the metric catalog. * - * The surface itself is the existing ``, reused verbatim — its - * non-diagnostic disclaimer + waveform logic are load-bearing and untouched. - * We only suppress its internal `` (via `hideHeading`) because + * The surface itself is `` — the list of strips. Its internal + * `` is suppressed (via `hideHeading`) because * `` already renders the page heading, mirroring how every other * routed sub-page lets the shell own the `

`. * - * The overview `` teaser on `/insights` stays as-is. + * A row opens `/insights/ecg/[id]`, which is where the trace, the metadata and + * the device's own result live. The overview `` teaser on + * `/insights` links to the same place. */ export default function InsightsEcgPage() { const { isAuthenticated } = useAuth(); @@ -76,11 +77,6 @@ export default function InsightsEcgPage() { title={t("insights.ecg.sectionTitle")} description={t("insights.subPage.ecgDescription")} > - {/* What the app does and does not claim about a trace. Its own - paragraph, so the page explainer above stays one sentence. */} -

- {t("insights.subPage.ecgDisclaimer")} -

); diff --git a/src/components/insights/__tests__/ecg-detail.test.tsx b/src/components/insights/__tests__/ecg-detail.test.tsx new file mode 100644 index 000000000..9c7c9b80e --- /dev/null +++ b/src/components/insights/__tests__/ecg-detail.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * `` — one recording, addressed by id. + * + * The non-diagnostic framing is the load-bearing part and it is unchanged by + * the move to a route: the result shown is the DEVICE's, attributed to the + * device, and a non-normal device result adds the "discuss with a clinician" + * note while a normal one does not. + * + * What did change: the component reads by id rather than being handed a row + * from the list, so a cold deep link works — the positive control below is + * that a render with only an id in hand still paints the trace. + */ + +const useQueryMock = vi.fn(); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (opts: unknown) => useQueryMock(opts), +})); + +const { EcgDetail } = await import("../ecg-detail"); +type EcgDetailResponse = import("../ecg-detail").EcgDetailResponse; + +const DETAIL: EcgDetailResponse = { + recordedAt: "2026-06-01T09:15:00.000Z", + durationSeconds: 30, + samplingFrequency: 300, + averageHeartRate: 72, + lead: null, + classification: "IRREGULAR", + source: "WITHINGS", + samples: [0, 10, -5, 40, -20, 5, 0], + decimated: true, +}; + +function renderDetail(detail: Partial = {}) { + useQueryMock.mockReturnValue({ + data: { ...DETAIL, ...detail }, + isLoading: false, + isError: false, + }); + return renderToStaticMarkup( + + + , + ); +} + +describe(" — non-diagnostic framing", () => { + it("attributes the result to the recording device and draws the waveform", () => { + const html = renderDetail(); + expect(html).toContain('data-slot="ecg-detail"'); + expect(html).toContain('data-slot="ecg-result"'); + // Load-bearing: the verdict is the device's, attributed to the device. + expect(html).toContain("as reported by the recording device"); + expect(html).toContain("Atrial fibrillation detected"); + expect(html).toContain('data-slot="ecg-trace"'); + }); + + it("adds the 'discuss with a clinician' note on a non-normal result", () => { + const html = renderDetail(); + expect(html).toContain('data-slot="ecg-clinician-note"'); + expect(html).toContain("discuss this recording with a clinician"); + }); + + it("adds the clinician note for an INCONCLUSIVE result", () => { + const html = renderDetail({ classification: "INCONCLUSIVE" }); + expect(html).toContain('data-slot="ecg-clinician-note"'); + }); + + it("does NOT add the clinician note for a normal (not-detected) result", () => { + const html = renderDetail({ classification: "NOT_DETECTED" }); + expect(html).not.toContain('data-slot="ecg-clinician-note"'); + // The device attribution is still there. + expect(html).toContain("as reported by the recording device"); + }); + + it("says a failed read failed instead of painting an empty recording", () => { + useQueryMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('data-slot="ecg-detail-error"'); + expect(html).not.toContain('data-slot="ecg-trace"'); + }); +}); diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx index 6497af8fd..eb663f15d 100644 --- a/src/components/insights/__tests__/ecg-section.test.tsx +++ b/src/components/insights/__tests__/ecg-section.test.tsx @@ -4,20 +4,19 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; /** - * v1.28.50 — `` / `` unit tests. + * `` — the list of recorded strips. * * The load-bearing behaviour under test: - * - data-availability gating: the section un-mounts entirely when the - * user has no recordings or while the payload is in flight; - * - the NON-DIAGNOSTIC framing (mirrors `RhythmEventsCard`): the device's - * result is shown attributed to the device, a permanent disclaimer - * states HealthLog does not interpret / diagnose, and any non-normal - * device result adds the "discuss with a clinician" note — while a - * normal result does NOT. + * - data-availability gating: the section un-mounts entirely when the user + * has no recordings or while the payload is in flight; + * - the reading cap: a device that records every morning accumulates + * hundreds of strips, and the list paints the most recent handful; + * - the device's verdict rides the timestamp as a tag, and a row that has + * a waveform opens the recording's own address. * * `useAuth` + TanStack Query are mocked and the assertions run through SSR * (the suite's node environment has no DOM), exactly like the sibling - * `rhythm-events-card` test. + * `rhythm-events-card` test. `` has its own spec. */ vi.mock("@/hooks/use-auth", () => ({ @@ -29,20 +28,8 @@ vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: unknown) => useQueryMock(opts), })); -const { EcgSection, EcgDetail } = await import("../ecg-section"); - -interface EcgRecordingListItem { - id: string; - recordedAt: string; - durationSeconds: number | null; - samplingFrequency: number; - sampleCount: number; - averageHeartRate: number | null; - lead: string | null; - classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; - source: string; - hasWaveform: boolean; -} +const { EcgSection, ECG_LIST_LIMIT } = await import("../ecg-section"); +type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem; const IRREGULAR_REC: EcgRecordingListItem = { id: "ecg_1", @@ -63,18 +50,6 @@ const NORMAL_REC: EcgRecordingListItem = { classification: "NOT_DETECTED", }; -const DETAIL = { - recordedAt: "2026-06-01T09:15:00.000Z", - durationSeconds: 30, - samplingFrequency: 300, - averageHeartRate: 72, - lead: null, - classification: "IRREGULAR" as const, - source: "WITHINGS", - samples: [0, 10, -5, 40, -20, 5, 0], - decimated: true, -}; - function renderSection( data: { recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined, @@ -87,20 +62,8 @@ function renderSection( ); } -function renderDetail( - recording: EcgRecordingListItem, - resultLabel: string | null, -) { - useQueryMock.mockReturnValue({ data: DETAIL, isLoading: false }); - return renderToStaticMarkup( - - {}} - /> - , - ); +function countRows(html: string): number { + return (html.match(/data-slot="ecg-row"/g) ?? []).length; } describe("", () => { @@ -119,65 +82,73 @@ describe("", () => { expect(html).toBe(""); }); - it("renders the recording list with a device-result label per row", () => { + it("renders one row per recording with the device result as a tag", () => { const html = renderSection({ recordings: [IRREGULAR_REC, NORMAL_REC], hasRecordings: true, }); expect(html).toContain('data-slot="ecg-card"'); expect(html).toContain('data-slot="ecg-list"'); - const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length; - expect(rows).toBe(2); + expect(countRows(html)).toBe(2); expect(html).toContain("Atrial fibrillation detected"); expect(html).toContain("No signs of atrial fibrillation"); + // The verdict sits beside the timestamp inside the row's own header + // line, not on a line of its own beneath it. + expect(html).toMatch( + /]*data-slot="ecg-row-result"[^>]*class="[^"]*rounded-full/, + ); }); - it("shows the permanent non-diagnostic disclaimer on the list", () => { + it("caps the list at the most recent handful", () => { + // Eight strips in, five out — and the five are the ones the route + // handed back first, which it orders `recordedAt desc`. + const recordings = Array.from({ length: 8 }, (_, index) => ({ + ...NORMAL_REC, + id: `ecg_${index}`, + recordedAt: new Date(Date.UTC(2026, 5, 20 - index, 9, 15)).toISOString(), + })); + const html = renderSection({ recordings, hasRecordings: true }); + + expect(ECG_LIST_LIMIT).toBe(5); + expect(countRows(html)).toBe(ECG_LIST_LIMIT); + for (const kept of recordings.slice(0, ECG_LIST_LIMIT)) { + expect(html, `${kept.id} should be listed`).toContain( + `/insights/ecg/${kept.id}`, + ); + } + for (const dropped of recordings.slice(ECG_LIST_LIMIT)) { + expect(html, `${dropped.id} is past the cap`).not.toContain( + `/insights/ecg/${dropped.id}`, + ); + } + }); + + it("opens the recording's own address when it has a waveform", () => { const html = renderSection({ recordings: [IRREGULAR_REC], hasRecordings: true, }); - expect(html).toContain('data-slot="ecg-disclaimer"'); - expect(html).toContain( - "HealthLog does not read or interpret ECG recordings", - ); - expect(html).toContain("does not provide a diagnosis"); + expect(html).toContain('href="/insights/ecg/ecg_1"'); }); -}); -describe(" — non-diagnostic framing", () => { - it("attributes the result to the recording device and draws the waveform", () => { - const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected"); - expect(html).toContain('data-slot="ecg-detail"'); - expect(html).toContain('data-slot="ecg-result"'); - // Load-bearing: the verdict is the device's, attributed to the device. - expect(html).toContain("as reported by the recording device"); - expect(html).toContain("Atrial fibrillation detected"); - // The waveform strip is rendered. - expect(html).toContain('data-slot="ecg-trace"'); - // And the permanent disclaimer is present here too. - expect(html).toContain('data-slot="ecg-disclaimer"'); + it("leaves a waveform-less recording unlinked", () => { + const html = renderSection({ + recordings: [{ ...IRREGULAR_REC, hasWaveform: false }], + hasRecordings: true, + }); + expect(countRows(html)).toBe(1); + expect(html).not.toContain('href="/insights/ecg/ecg_1"'); }); - it("adds the 'discuss with a clinician' note on a non-normal result", () => { - const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected"); - expect(html).toContain('data-slot="ecg-clinician-note"'); - expect(html).toContain("discuss this recording with a clinician"); - }); - - it("adds the clinician note for an INCONCLUSIVE result", () => { - const html = renderDetail( - { ...IRREGULAR_REC, classification: "INCONCLUSIVE" }, - "Inconclusive recording", - ); - expect(html).toContain('data-slot="ecg-clinician-note"'); - }); - - it("does NOT add the clinician note for a normal (not-detected) result", () => { - const html = renderDetail(NORMAL_REC, "No signs of atrial fibrillation"); - expect(html).not.toContain('data-slot="ecg-clinician-note"'); - // But the disclaimer + device attribution are still present. - expect(html).toContain('data-slot="ecg-disclaimer"'); - expect(html).toContain("as reported by the recording device"); + it("carries no intro paragraph and no disclaimer block", () => { + // Both were removed: the page heading already says what the surface is, + // and the device attribution on the detail page is where the "this is + // the device's result" statement belongs. + const html = renderSection({ + recordings: [IRREGULAR_REC], + hasRecordings: true, + }); + expect(html).not.toContain('data-slot="ecg-disclaimer"'); + expect(html).not.toContain("Single-lead ECG strips"); }); }); diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx new file mode 100644 index 000000000..8c2f5fc11 --- /dev/null +++ b/src/components/insights/ecg-detail.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { EcgWaveform } from "@/components/insights/ecg-waveform"; +import { InsightSectionCard } from "@/components/insights/insight-section-card"; +import { + ecgResultLabel, + isNonNormalEcg, + type EcgClassification, +} from "@/lib/insights/ecg-classification"; + +/** + * One ECG recording: the trace, the metadata, and the DEVICE's own result. + * + * NON-DIAGNOSTIC: the result shown is the recording device's, attributed to + * the device ("Recorded result: …, as reported by the recording device"). + * HealthLog reads nothing off the curve. A non-normal device result adds the + * "discuss with a clinician" note. Plain React text children throughout. + * + * The component owns its own read, addressed by id, so the detail page needs + * nothing from the list it was opened from — a deep link works cold. + */ + +export interface EcgDetailResponse { + recordedAt: string; + durationSeconds: number | null; + samplingFrequency: number; + averageHeartRate: number | null; + lead: string | null; + classification: EcgClassification; + source: string; + samples: number[]; + decimated: boolean; +} + +export function EcgDetail({ recordingId }: { recordingId: string }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.insightsEcgDetail(recordingId, false), + queryFn: async () => { + try { + return await apiGet( + `/api/insights/ecg/${recordingId}`, + ); + } catch { + throw new Error(t("insights.ecg.loadError")); + } + }, + }); + + if (isError) { + // A failed read must never read as "no data" (UI-STANDARDS §6). + return ( +

+ {t("insights.ecg.loadError")} +

+ ); + } + + if (isLoading || !data) { + return ( +
+ ); + } + + const resultLabel = ecgResultLabel(data.classification, t); + const leadLabel = data.lead ?? t("insights.ecg.meta.leadSingle"); + const durationLabel = + data.durationSeconds != null + ? t("insights.ecg.meta.durationValue", { + seconds: Math.round(data.durationSeconds), + }) + : t("insights.ecg.meta.unknown"); + + return ( + + {/* The DEVICE's result, attributed to the device. This is the ONLY + verdict shown — HealthLog produces none. */} + {resultLabel && ( +

+ {t("insights.ecg.resultAttribution", { result: resultLabel })} +

+ )} + + + +
+ + + + + 0 + ? t("insights.ecg.meta.hzValue", { hz: data.samplingFrequency }) + : t("insights.ecg.meta.unknown") + } + /> +
+ + {/* "Discuss with a clinician" — only on a non-normal device result. */} + {isNonNormalEcg(data.classification) && ( +

+ {t("insights.ecg.clinicianNote")} +

+ )} +
+ ); +} + +function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index a5fe8a473..ab6ed1669 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; -import { Activity, ChevronLeft, HeartPulse, Info } from "lucide-react"; +import { Activity, ChevronRight, HeartPulse } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -10,35 +10,41 @@ import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; -import { EcgWaveform } from "@/components/insights/ecg-waveform"; +import { + ecgResultLabel, + type EcgClassification, +} from "@/lib/insights/ecg-classification"; import { InsightSectionCard } from "./insight-section-card"; /** - * v1.28.50 — ECG recording surface (list → detail). + * v1.28.50 — ECG recording list. * - * Renders the user's ECG recordings synced from a single-lead device - * (Withings ScanWatch today): a list of strips, each opening a detail view - * with the `EcgWaveform` trace, the recording metadata, and the DEVICE's - * own classification. + * The user's ECG recordings synced from a single-lead device (Withings + * ScanWatch today): one row per strip, each opening `/insights/ecg/` + * with the trace, the metadata and the DEVICE's own classification. * - * NON-DIAGNOSTIC (load-bearing, do not soften — mirrors `RhythmEventsCard`): - * the surface shows only the waveform (raw data), the metadata, and the - * recording device's own result, attributed unambiguously to the device - * ("Recorded result: …, as reported by the recording device"). HealthLog - * generates NO interpretation of the waveform — no measured intervals, no - * beat/P/QRS/T annotation, no risk score, no verdict of its own. A - * permanent, non-dismissible disclaimer states this, and any non-normal - * device result additionally carries a "discuss with a clinician" note. - * All copy renders as plain React text children (no markdown library — the - * standing XSS rule). + * NON-DIAGNOSTIC: the surface shows the waveform (raw data), the metadata + * and the recording device's own result, attributed to the device on the + * detail page. HealthLog generates NO interpretation of the waveform — no + * measured intervals, no beat/P/QRS/T annotation, no risk score, no verdict + * of its own. All copy renders as plain React text children (no markdown + * library — the standing XSS rule). + * + * The list is capped: a device that records every morning accumulates + * hundreds of strips, and a page that paints all of them is a scroll, not a + * history. The most recent handful is what the surface is for. * * Data-availability-gated: the section un-mounts entirely (`return null`) * when the user has no recordings — never an empty / alarming card. */ -type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; +/** + * How many strips the list paints. The API already caps its own read at 200; + * this is the reading limit, not the fetch limit. + */ +export const ECG_LIST_LIMIT = 5; -interface EcgRecordingListItem { +export interface EcgRecordingListItem { id: string; recordedAt: string; durationSeconds: number | null; @@ -56,30 +62,6 @@ interface EcgListResponse { hasRecordings: boolean; } -interface EcgDetailResponse { - recordedAt: string; - durationSeconds: number | null; - samplingFrequency: number; - averageHeartRate: number | null; - lead: string | null; - classification: EcgClassification; - source: string; - samples: number[]; - decimated: boolean; -} - -/** The device verdict, surfaced verbatim and attributed to the device. */ -const RESULT_LABEL_KEYS: Record = { - IRREGULAR: "insights.ecg.result.irregular", - NOT_DETECTED: "insights.ecg.result.notDetected", - INCONCLUSIVE: "insights.ecg.result.inconclusive", -}; - -/** Non-normal device results trigger the "discuss with a clinician" note. */ -function isNonNormal(classification: EcgClassification): boolean { - return classification === "IRREGULAR" || classification === "INCONCLUSIVE"; -} - interface EcgSectionProps { enabled?: boolean; className?: string; @@ -87,8 +69,7 @@ interface EcgSectionProps { * v1.30 — suppress the internal `` when the section is * hosted on the routed `/insights/ecg` sub-page, whose `` * already renders the page `

`. Defaults to `false` so the overview - * teaser keeps its own heading unchanged. Purely presentational — the - * non-diagnostic disclaimer + waveform logic are untouched. + * teaser keeps its own heading unchanged. */ hideHeading?: boolean; } @@ -101,7 +82,6 @@ export function EcgSection({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); - const [selectedId, setSelectedId] = useState(null); const { data, isLoading } = useQuery({ queryKey: queryKeys.insightsEcgList(), @@ -118,16 +98,9 @@ export function EcgSection({ // Data-availability gate — never paint an empty card. if (isLoading || !data || !data.hasRecordings) return null; - const selected = - selectedId != null - ? (data.recordings.find((r) => r.id === selectedId) ?? null) - : null; - - function resultLabel(classification: EcgClassification): string | null { - if (!classification) return null; - const key = RESULT_LABEL_KEYS[classification]; - return key ? t(key) : null; - } + // The route hands them back most-recent-first; the slice is the reading + // limit on top of that, so the newest strips are the ones on screen. + const recordings = data.recordings.slice(0, ECG_LIST_LIMIT); return (
)} - {selected ? ( - setSelectedId(null)} - /> - ) : ( - <> -

- {t("insights.ecg.sectionIntro")} -

-
    - {data.recordings.map((rec) => { - const label = resultLabel(rec.classification); - const rowInner = ( - <> - - - -
    -

    - {fmt.dateTime(new Date(rec.recordedAt))} -

    - {label && ( -

    - {label} -

    - )} - {rec.averageHeartRate != null && ( -

    - {t("insights.ecg.meta.bpmValue", { - bpm: rec.averageHeartRate, - })} -

    - )} -
    - - ); - return ( -
  1. - {rec.hasWaveform ? ( - - ) : ( -
    {rowInner}
    + {label} + )} -
  2. - ); - })} -
- - {/* Load-bearing regulatory disclaimer — permanent, - non-dismissible. Plain React text children (no markdown). */} - - - )} +

+ {rec.averageHeartRate != null && ( +

+ {t("insights.ecg.meta.bpmValue", { + bpm: rec.averageHeartRate, + })} +

+ )} +

+ + ); + return ( +
  • + {rec.hasWaveform ? ( + + {rowInner} +
  • + ); + })} +
    ); } - -/** - * The detail view: waveform + metadata + device-attributed result + - * permanent disclaimer + clinician note on a non-normal device result. - * Exported so the non-diagnostic framing can be unit-tested directly. - */ -export function EcgDetail({ - recording, - resultLabel, - onBack, -}: { - recording: EcgRecordingListItem; - resultLabel: string | null; - onBack: () => void; -}) { - const { t } = useTranslations(); - const fmt = useFormatters(); - - const { data: detail, isLoading } = useQuery({ - queryKey: queryKeys.insightsEcgDetail(recording.id, false), - queryFn: async () => { - try { - return await apiGet( - `/api/insights/ecg/${recording.id}`, - ); - } catch { - throw new Error(t("insights.ecg.loadError")); - } - }, - }); - - const nonNormal = isNonNormal(recording.classification); - const leadLabel = recording.lead ?? t("insights.ecg.meta.leadSingle"); - const durationLabel = - recording.durationSeconds != null - ? t("insights.ecg.meta.durationValue", { - seconds: Math.round(recording.durationSeconds), - }) - : t("insights.ecg.meta.unknown"); - - return ( -
    - - - {/* The DEVICE's result, attributed to the device. This is the ONLY - verdict shown — HealthLog produces none. */} - {resultLabel && ( -

    - {t("insights.ecg.resultAttribution", { result: resultLabel })} -

    - )} - - {isLoading || !detail ? ( -
    - ) : ( - - )} - -
    - - - - - 0 - ? t("insights.ecg.meta.hzValue", { - hz: recording.samplingFrequency, - }) - : t("insights.ecg.meta.unknown") - } - /> -
    - - {/* "Discuss with a clinician" — only on a non-normal device result. */} - {nonNormal && ( -

    - {t("insights.ecg.clinicianNote")} -

    - )} - - -
    - ); -} - -function MetaItem({ label, value }: { label: string; value: string }) { - return ( -
    -
    {label}
    -
    {value}
    -
    - ); -} - -/** - * The permanent, non-dismissible non-diagnostic disclaimer. States that the - * displayed result is the device's and that HealthLog does not read, - * interpret, or diagnose ECG recordings. - */ -function EcgDisclaimer() { - const { t } = useTranslations(); - return ( -
    -
    - ); -} diff --git a/src/lib/insights/ecg-classification.ts b/src/lib/insights/ecg-classification.ts new file mode 100644 index 000000000..42883c274 --- /dev/null +++ b/src/lib/insights/ecg-classification.ts @@ -0,0 +1,36 @@ +/** + * The recording device's own rhythm verdict, and the two things every ECG + * surface needs to say about it. + * + * The list, the detail page and the cross-link card all render this value and + * all three used to carry their own copy of the label map. It is one map now, + * so a fourth surface cannot introduce a fifth wording. + * + * HealthLog produces NO verdict of its own — no measured intervals, no + * beat/P/QRS/T annotation, no risk score. Everything here is the device's + * result, passed through. + */ +export type EcgClassification = + "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; + +/** i18n key per device verdict, under `insights.ecg.result.*`. */ +export const ECG_RESULT_LABEL_KEYS: Record = { + IRREGULAR: "insights.ecg.result.irregular", + NOT_DETECTED: "insights.ecg.result.notDetected", + INCONCLUSIVE: "insights.ecg.result.inconclusive", +}; + +/** Resolve the device verdict to its localised label, or null when unset. */ +export function ecgResultLabel( + classification: EcgClassification, + t: (key: string) => string, +): string | null { + if (!classification) return null; + const key = ECG_RESULT_LABEL_KEYS[classification]; + return key ? t(key) : null; +} + +/** A non-normal device result carries the "discuss with a clinician" note. */ +export function isNonNormalEcg(classification: EcgClassification): boolean { + return classification === "IRREGULAR" || classification === "INCONCLUSIVE"; +} From 95988ee8435fe2b99471f4a5440d4af2494d8a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:56:06 +0200 Subject: [PATCH 07/12] fix(nav): drop the access-level line from a record-switcher entry Three lines in a 240 px menu: the name, the access level, the kind of record. The middle one truncated more often than it read, and it is what the warning banner states in full once you are inside the record, where it governs what the page lets you do. Two lines left, and the level stays on the row as `data-access-level` for the browser journeys. The switcher's own "Read only" / "Can add entries" strings had no other reader and leave all six bundles. --- e2e/account-sharing.spec.ts | 9 +++++++++ messages/de.json | 4 +--- messages/en.json | 4 +--- messages/es.json | 4 +--- messages/fr.json | 4 +--- messages/it.json | 4 +--- messages/pl.json | 4 +--- .../layout/account-switcher-menu.tsx | 20 +++++++------------ 8 files changed, 22 insertions(+), 31 deletions(-) diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index 0046c15d5..f721792c1 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -313,6 +313,15 @@ test.describe("account sharing", () => { const entry = page.locator('[data-slot="account-switcher-entry"]'); await expect(entry).toHaveCount(1); await expect(entry).toContainText(E2E_OWNER.username); + // Two lines: the name and the kind of record. The access level used to + // take a third and was truncated more often than it was read; it is what + // the banner says once you are inside. The row still CARRIES the level as + // an attribute, which is the positive control for the absence below — + // without it, a renamed slot would make this read green. + await expect(entry).toHaveAttribute("data-access-level", /.+/); + await expect(entry).not.toContainText("Read only"); + await expect(entry).not.toContainText("Can add entries"); + await expect(entry).not.toContainText("change or remove what is in it"); // The way home is in the same menu as the way in. await expect( page.locator('[data-slot="account-switcher-own"]'), diff --git a/messages/de.json b/messages/de.json index 2ca8e44a9..0f4263acf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9677,9 +9677,7 @@ }, "switcher": { "label": "Akte öffnen", - "ownRecord": "Meine eigene Akte", - "readOnly": "Nur lesen", - "canWrite": "Darf Einträge anlegen" + "ownRecord": "Meine eigene Akte" }, "switch": { "failed": "Die Akte konnte nicht geöffnet werden. Bitte noch einmal versuchen.", diff --git a/messages/en.json b/messages/en.json index cce8b2ea2..3fb6bdf44 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9677,9 +9677,7 @@ }, "switcher": { "label": "Open a record", - "ownRecord": "My own record", - "readOnly": "Read only", - "canWrite": "Can add entries" + "ownRecord": "My own record" }, "switch": { "failed": "Could not open that record. Please try again.", diff --git a/messages/es.json b/messages/es.json index 14df01c07..80c5f542a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9677,9 +9677,7 @@ }, "switcher": { "label": "Abrir un historial", - "ownRecord": "Mi propio historial", - "readOnly": "Solo lectura", - "canWrite": "Puede añadir entradas" + "ownRecord": "Mi propio historial" }, "switch": { "failed": "No se pudo abrir ese historial. Inténtalo de nuevo.", diff --git a/messages/fr.json b/messages/fr.json index 1510ead0c..1c0d89f63 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9677,9 +9677,7 @@ }, "switcher": { "label": "Ouvrir un dossier", - "ownRecord": "Mon propre dossier", - "readOnly": "Lecture seule", - "canWrite": "Peut ajouter des entrées" + "ownRecord": "Mon propre dossier" }, "switch": { "failed": "Impossible d'ouvrir ce dossier. Veuillez réessayer.", diff --git a/messages/it.json b/messages/it.json index c0d97c4ef..6e485828c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9677,9 +9677,7 @@ }, "switcher": { "label": "Apri una cartella", - "ownRecord": "La mia cartella", - "readOnly": "Sola lettura", - "canWrite": "Può aggiungere voci" + "ownRecord": "La mia cartella" }, "switch": { "failed": "Non è stato possibile aprire quella cartella. Riprova.", diff --git a/messages/pl.json b/messages/pl.json index 90eb7eda8..4b433e175 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9677,9 +9677,7 @@ }, "switcher": { "label": "Otwórz kartę", - "ownRecord": "Moja własna karta", - "readOnly": "Tylko odczyt", - "canWrite": "Może dodawać wpisy" + "ownRecord": "Moja własna karta" }, "switch": { "failed": "Nie udało się otworzyć tej karty. Spróbuj ponownie.", diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx index 8fac8a3b6..54abd94ca 100644 --- a/src/components/layout/account-switcher-menu.tsx +++ b/src/components/layout/account-switcher-menu.tsx @@ -101,11 +101,15 @@ export function AccountSwitcherMenuItems() { ) } > + {/* Two lines, not three. The access level had a line of its + own here and it never fit — a 240 px menu truncated + "Kann verwalten" as often as it showed it, and the entry + still said which kind of record this is on the line below. + The level is what the banner states once you are inside, + where it governs what the page lets you do. It stays on + the row as `data-access-level` for the browser journeys. */}
    {accountLabel(account)} - - {accessLabel(presentation.access, t)} - {recordKindLabel(presentation.recordKind, t)} @@ -122,16 +126,6 @@ export function AccountSwitcherMenuItems() { ); } -function accessLabel( - access: ReturnType["access"], - t: ReturnType["t"], -): string { - if (access === "manage") return t("recordSharing.row.canManage"); - return access === "view-and-add" - ? t("recordSharing.switcher.canWrite") - : t("recordSharing.switcher.readOnly"); -} - function recordKindLabel( recordKind: ReturnType["recordKind"], t: ReturnType["t"], From 65a3d91b6ebfd9085d1edc33f75fcc09d6775baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 15:16:19 +0200 Subject: [PATCH 08/12] docs: record the settings and ECG polish under Unreleased MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile-consistency spec follows the change-password card to Security — same claim, new address. --- CHANGELOG.md | 43 +++++++++++++++++++++++++ e2e/settings-mobile-consistency.spec.ts | 16 ++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 154891c6c..d13a2a9fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,49 @@ optimistic paint had promised this all along, and the skipped-to-taken flip in the same view already behaved this way. +- The Appearance list in Settings had a strip of dead space above every row + that the first row did not have — 24 px on a desktop, 16 px on a phone, + unclickable, sitting between one module and the next. The rows read a step + apart from the top of their own list. They now sit the same distance from + the line above them, and the geometry sweep measures it so it cannot drift + back. + +- The Account settings page is the profile again. Cycle tracking is a module + and its switch lives in the Modules hub, so the second on-ramp here is gone + along with the "manage in Account" link beside that switch, which pointed at + an anchor nothing rendered. Changing the password moved to Security, where + the second factors and the passkeys already are, and it leads that page. + +- Five module rows ran two to three times longer than their thirteen + neighbours: environment, external assistant access, documents, mental + wellbeing and nutrient intake. Every one of them ended in "off by default", + restating what the switch beside the row already shows. They are one + sentence now, like the rest. The mental-wellbeing row also named two + screeners where the module ships four, and what happens to your answers is + now said on the mental-wellbeing page itself, where you are about to give + them. + +- Research Mode is retired. It was an opt-in for the estimated GLP-1 level + curve, but that chart stopped consulting the flag several releases ago and + has been visible to everyone since — so the switch on the Advanced page + changed no screen in either position. The curve is simply part of the + medication page, and the switch, its acknowledgment dialog, and the stored + acknowledgment are gone. Advanced is now what it reads as: reset the data, + delete the account. + +- The ECG page opened with three lines about what HealthLog does not do + before showing a single recording, two of them saying the same thing. They + are gone; the recording's own page still states that the result shown is + the device's. The device's verdict now rides the timestamp as a tag instead + of taking a line of its own, the list shows the five most recent strips + rather than every one ever recorded, and a recording has its own address + with the back link above the heading, so a strip can be linked to and the + browser's own Back works. + +- An entry in the record switcher no longer carries the access level on a + line of its own. In a menu that narrow it was truncated more often than it + was read, and the banner states it in full once you are inside the record. + ## [1.37.1] — 2026-08-08 ### Added diff --git a/e2e/settings-mobile-consistency.spec.ts b/e2e/settings-mobile-consistency.spec.ts index 01e284630..b9b67519e 100644 --- a/e2e/settings-mobile-consistency.spec.ts +++ b/e2e/settings-mobile-consistency.spec.ts @@ -93,22 +93,22 @@ test.describe("Settings mobile consistency (Pixel 5)", () => { } }); - test("/settings/account: action buttons do not overflow their cards", async ({ + test("/settings/security: action buttons do not overflow their cards", async ({ page, }) => { - await page.goto("/settings/account", { waitUntil: "networkidle" }); + // The change-password card sits on Security now, beside the second + // factors and the passkeys. The claim is unchanged; the address is not. + await page.goto("/settings/security", { waitUntil: "networkidle" }); // Same reason as the grid test below: gate on the element, not the // network, because the read underneath it happens exactly once. await expect( page.getByRole("button", { name: /change password/i }), ).toBeVisible(); - // The account page's action button(s) must live within (or above the - // bottom of) the parent card. Since v1.18.1 the "restart onboarding" - // tour button moved to Settings → Advanced, so account carries the - // change-password button; the tour button is matched here too in case - // it is present. On mobile the button stacks below the title — it's + // The page's action button(s) must live within (or above the bottom of) + // the parent card. On mobile the button stacks below the title — it is // allowed to extend the card's height, but not push past the right edge. + // The German "Passwort ändern" is the long case this was written for. const overflowCheck = await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll("button")); const targets = buttons.filter((b) => { @@ -134,7 +134,7 @@ test.describe("Settings mobile consistency (Pixel 5)", () => { expect( overflowCheck.length, - "expected to find the change-password action button on /settings/account", + "expected to find the change-password action button on /settings/security", ).toBeGreaterThanOrEqual(1); for (const t of overflowCheck) { From 8f3e4b63873e3b1316079a4924854e841b6b1578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:06:57 +0200 Subject: [PATCH 09/12] fix(insights): cap the ECG teaser, never the ECG page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five-strip cap belonged on the overview teaser — a glance on /insights — not on the surface a user opens to read their history. It was living in the shared EcgSection, which the dedicated /insights/ecg page reuses, so an account with many recordings could only ever reach the newest five there and the older strips became unreachable with no pagination behind them. The cap is a prop now. The teaser passes ECG_OVERVIEW_LIMIT; the page passes nothing and lists every recording. The constant lives in the React-free classification module so the overview page can pass it without pulling the dynamically-loaded ECG component into its bundle. --- src/app/insights/ecg/__tests__/page.test.tsx | 20 ++++++++ src/app/insights/page-client.tsx | 5 +- .../insights/__tests__/ecg-section.test.tsx | 47 ++++++++++++++----- src/components/insights/ecg-section.tsx | 28 +++++++---- src/lib/insights/ecg-classification.ts | 9 ++++ 5 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx index fec827e45..d516d91f6 100644 --- a/src/app/insights/ecg/__tests__/page.test.tsx +++ b/src/app/insights/ecg/__tests__/page.test.tsx @@ -76,6 +76,26 @@ describe("/insights/ecg page (H1)", () => { expect(html).not.toContain(" { + // The teaser on `/insights` caps at five; this dedicated page must not. + // An account with many strips reaches all of them here, or the older ones + // become unreachable with no pagination behind them. Eight in, eight out. + const recordings = Array.from({ length: 8 }, (_, index) => ({ + ...seededRecording(), + id: `rec-${index}`, + recordedAt: new Date(Date.UTC(2026, 6, 10 - index, 8, 0)).toISOString(), + })); + const html = render({ recordings, hasRecordings: true }); + + const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length; + expect(rows).toBe(8); + for (const rec of recordings) { + expect(html, `${rec.id} must be reachable on the full page`).toContain( + `/insights/ecg/${rec.id}`, + ); + } + }); + it("carries no disclaimer paragraph above the list", () => { // Two muted lines used to lead this page — one from the shell's own // explainer slot and one hand-rolled beneath it, both saying HealthLog diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 7d7d1dc9e..1f7f54db6 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -9,6 +9,9 @@ import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +// Pure constant — importing it here does NOT pull the dynamically-loaded ECG +// component into this bundle, which is exactly why it lives in the lib module. +import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-classification"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; @@ -527,7 +530,7 @@ export default function InsightsPageClient() { "health-status": , breathing: , "labs-changes": , - ecg: , + ecg: , }; const orderedSectionIds = orderedVisibleSectionIds(layout); diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx index eb663f15d..8dff451da 100644 --- a/src/components/insights/__tests__/ecg-section.test.tsx +++ b/src/components/insights/__tests__/ecg-section.test.tsx @@ -28,7 +28,7 @@ vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: unknown) => useQueryMock(opts), })); -const { EcgSection, ECG_LIST_LIMIT } = await import("../ecg-section"); +const { EcgSection, ECG_OVERVIEW_LIMIT } = await import("../ecg-section"); type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem; const IRREGULAR_REC: EcgRecordingListItem = { @@ -53,11 +53,12 @@ const NORMAL_REC: EcgRecordingListItem = { function renderSection( data: { recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined, + props: { limit?: number } = {}, ) { useQueryMock.mockReturnValue({ data, isLoading: false }); return renderToStaticMarkup( - + , ); } @@ -99,30 +100,52 @@ describe("", () => { ); }); - it("caps the list at the most recent handful", () => { - // Eight strips in, five out — and the five are the ones the route - // handed back first, which it orders `recordedAt desc`. - const recordings = Array.from({ length: 8 }, (_, index) => ({ + const eightRecordings = () => + Array.from({ length: 8 }, (_, index) => ({ ...NORMAL_REC, id: `ecg_${index}`, recordedAt: new Date(Date.UTC(2026, 5, 20 - index, 9, 15)).toISOString(), })); - const html = renderSection({ recordings, hasRecordings: true }); - expect(ECG_LIST_LIMIT).toBe(5); - expect(countRows(html)).toBe(ECG_LIST_LIMIT); - for (const kept of recordings.slice(0, ECG_LIST_LIMIT)) { + it("caps the OVERVIEW teaser at the most recent handful", () => { + // The teaser passes `limit`. Eight strips in, five out — and the five are + // the ones the route handed back first, which it orders `recordedAt desc`. + const recordings = eightRecordings(); + const html = renderSection( + { recordings, hasRecordings: true }, + { limit: ECG_OVERVIEW_LIMIT }, + ); + + expect(ECG_OVERVIEW_LIMIT).toBe(5); + expect(countRows(html)).toBe(ECG_OVERVIEW_LIMIT); + for (const kept of recordings.slice(0, ECG_OVERVIEW_LIMIT)) { expect(html, `${kept.id} should be listed`).toContain( `/insights/ecg/${kept.id}`, ); } - for (const dropped of recordings.slice(ECG_LIST_LIMIT)) { - expect(html, `${dropped.id} is past the cap`).not.toContain( + for (const dropped of recordings.slice(ECG_OVERVIEW_LIMIT)) { + expect(html, `${dropped.id} is past the teaser cap`).not.toContain( `/insights/ecg/${dropped.id}`, ); } }); + it("renders every recording when NO limit is given (the full page)", () => { + // The dedicated `/insights/ecg` page passes no `limit`, so an account with + // many strips reaches all of them — the cap is a teaser affordance, never + // a wall on the full surface. Eight seeded, eight rendered, every one + // reachable by its own address. + const recordings = eightRecordings(); + const html = renderSection({ recordings, hasRecordings: true }); + + expect(countRows(html)).toBe(8); + for (const rec of recordings) { + expect(html, `${rec.id} must be reachable on the full page`).toContain( + `/insights/ecg/${rec.id}`, + ); + } + }); + it("opens the recording's own address when it has a waveform", () => { const html = renderSection({ recordings: [IRREGULAR_REC], diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index ab6ed1669..eee82e990 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -12,10 +12,16 @@ import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; import { ecgResultLabel, + ECG_OVERVIEW_LIMIT, type EcgClassification, } from "@/lib/insights/ecg-classification"; import { InsightSectionCard } from "./insight-section-card"; +// Re-exported so the tests (and any teaser call site) can keep importing the +// overview cap from the component they are exercising; the value itself lives +// in the React-free lib module. +export { ECG_OVERVIEW_LIMIT }; + /** * v1.28.50 — ECG recording list. * @@ -38,12 +44,6 @@ import { InsightSectionCard } from "./insight-section-card"; * when the user has no recordings — never an empty / alarming card. */ -/** - * How many strips the list paints. The API already caps its own read at 200; - * this is the reading limit, not the fetch limit. - */ -export const ECG_LIST_LIMIT = 5; - export interface EcgRecordingListItem { id: string; recordedAt: string; @@ -72,12 +72,20 @@ interface EcgSectionProps { * teaser keeps its own heading unchanged. */ hideHeading?: boolean; + /** + * Cap the number of strips shown. The overview teaser passes + * `ECG_OVERVIEW_LIMIT`; the dedicated `/insights/ecg` page passes nothing, + * so it renders the full list. Omitted = no cap: a full surface must never + * silently hide a recording behind a limit with no way to reach it. + */ + limit?: number; } export function EcgSection({ enabled = true, className, hideHeading = false, + limit, }: EcgSectionProps) { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -98,9 +106,11 @@ export function EcgSection({ // Data-availability gate — never paint an empty card. if (isLoading || !data || !data.hasRecordings) return null; - // The route hands them back most-recent-first; the slice is the reading - // limit on top of that, so the newest strips are the ones on screen. - const recordings = data.recordings.slice(0, ECG_LIST_LIMIT); + // The route hands them back most-recent-first. A `limit` (the overview + // teaser) keeps the newest few; no limit (the dedicated page) shows them + // all, so an older strip is never made unreachable. + const recordings = + limit != null ? data.recordings.slice(0, limit) : data.recordings; return (
    = { IRREGULAR: "insights.ecg.result.irregular", From 73ea1b03f3848735755f073b8229b36333095dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:07:06 +0200 Subject: [PATCH 10/12] fix(scripts): drop the retired research-mode columns from the demo rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo edge01 rebuild copies the users table by an explicit column list, and three of the names in it — research_mode_enabled and its two acknowledgment stamps — were dropped from the schema when Research Mode was retired. The COPY and its matching SELECT both targeted columns that no longer exist, so the rebuild would fail on the first users row. Removed from both lists, which stay aligned. --- scripts/migrate-demo-edge01.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index 76dc3e1c0..4ecf556c3 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -325,7 +325,6 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat onboarding_completed_at, onboarding_step, onboarding_tour_completed, healthkit_config_json, healthkit_last_synced_at, display_name, last_report_practice_name, coach_prefs_json, source_priority_json, - research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version, last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs, disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at, full_name, insurer_name, insurance_number_encrypted, insurer_ik_number" @@ -366,7 +365,6 @@ readonly USERS_SELECT="id, username, email, password_hash, onboarding_completed_at, onboarding_step, onboarding_tour_completed, healthkit_config_json, healthkit_last_synced_at, display_name, last_report_practice_name, coach_prefs_json, source_priority_json, - research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version, last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs, disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at, full_name, From e33700c7a9103807aee52c17e064dc60c3e71518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:07:25 +0200 Subject: [PATCH 11/12] feat(sharing): name a record's owner in full to whoever they share with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switcher and the banner named a shared record's owner by their greeting name — the value they typed at onboarding for "how should HealthLog address you". When an owner has set a full name, that is what a delegate should see: entering somebody's record, you read whose it is by who they are. - the account-access resolver publishes the owner's `fullName` alongside the greeting name and the username - `accountLabel` prefers fullName, then displayName, then username. Optional on the argument so the grant-view and actor shapes that never carried it keep the old two-field behaviour untouched - the closed presentation schema carries `fullName` and its parity matcher compares it, so the two published views of the active record cannot name the same owner differently - the OpenAPI contract regenerates with the additive field This deliberately discloses the owner's full name to every delegate, read-only included — it was patient-identity data behind a MANAGE-gated export until now. That is the intended behaviour, documented at `accountLabel` so it is not mistaken for a leak and tightened back. The resolver, the schema parity, the label fallback and a read-only delegate's banner are each pinned by a test. --- docs/api/openapi.yaml | 10 +++ e2e/account-sharing.spec.ts | 18 ++++- e2e/delegated-writes.spec.ts | 5 +- e2e/setup/global-setup.ts | 14 +++- e2e/setup/test-helpers.ts | 1 + .../take-all-due-affordance.test.tsx | 1 + .../delegated-write-affordances.test.tsx | 1 + .../__tests__/chart-overlay-controls.test.tsx | 1 + .../auth-shell-shared-record-doors.test.tsx | 2 + .../__tests__/shared-record-chrome.test.tsx | 20 ++++++ .../managed-profile-affordances.test.tsx | 1 + .../use-auth-account-access-state.test.ts | 1 + .../__tests__/use-record-capabilities.test.ts | 1 + .../__tests__/coach-launch-context.test.tsx | 1 + .../__tests__/account-access-schema.test.ts | 6 ++ .../sharing/__tests__/account-access.test.ts | 26 ++++++++ .../sharing/__tests__/account-label.test.ts | 65 +++++++++++++++++++ src/lib/sharing/account-access-schema.ts | 5 ++ src/lib/sharing/account-access-view.ts | 31 +++++++-- src/lib/sharing/account-access.ts | 5 ++ 20 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/lib/sharing/__tests__/account-label.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33fb729bd..b18ff18e1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -38831,6 +38831,10 @@ components: anyOf: - type: string - type: "null" + fullName: + anyOf: + - type: string + - type: "null" access: type: string enum: @@ -38868,6 +38872,7 @@ components: - accountId - username - displayName + - fullName - access - level - recordKind @@ -38888,6 +38893,10 @@ components: anyOf: - type: string - type: "null" + fullName: + anyOf: + - type: string + - type: "null" access: type: string enum: @@ -38925,6 +38934,7 @@ components: - accountId - username - displayName + - fullName - access - level - recordKind diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index f721792c1..618ff1ee5 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -69,6 +69,7 @@ import { DELEGATE_STORAGE_STATE_PATH, E2E_DELEGATE_MARKER_KG, E2E_OWNER, + E2E_OWNER_FULL_NAME, E2E_USER, OWNER_STORAGE_STATE_PATH, } from "./setup/test-helpers"; @@ -312,7 +313,17 @@ test.describe("account sharing", () => { const entry = page.locator('[data-slot="account-switcher-entry"]'); await expect(entry).toHaveCount(1); - await expect(entry).toContainText(E2E_OWNER.username); + // v1.37.2 — the owner set a full name, so a delegate sees them by it + // rather than by the greeting name or the login handle. This is the + // record-owner's full name crossing to a read-only delegate, which is the + // deliberate disclosure the maintainer decided on. The username stays on + // the row as an attribute, which is the positive control for the label + // assertion — without it, a renamed slot would make this read green. + await expect(entry).toContainText(E2E_OWNER_FULL_NAME); + await expect(entry).toHaveAttribute( + "data-account-username", + E2E_OWNER.username, + ); // Two lines: the name and the kind of record. The access level used to // take a third and was truncated more often than it was read; it is what // the banner says once you are inside. The row still CARRIES the level as @@ -351,10 +362,11 @@ test.describe("account sharing", () => { // Wait for CONTENT, and for content the assertion is not about: the // banner naming the owner. A skeleton does not satisfy it, and it says - // nothing about the marker either way. + // nothing about the marker either way. The owner set a full name, so the + // banner names them by it (v1.37.2) — the same label the switcher used. const banner = page.locator('[data-slot="shared-record-banner"]'); await expect(banner).toBeVisible(); - await expect(banner).toContainText(E2E_OWNER.username); + await expect(banner).toContainText(E2E_OWNER_FULL_NAME); // The document showing the banner has never shown the delegate's own // number. The latch was armed by that document's own init script, so this diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 42d7a972c..2957dc2d7 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -39,7 +39,7 @@ import type { BrowserContext, Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { DELEGATE_STORAGE_STATE_PATH, - E2E_OWNER, + E2E_OWNER_FULL_NAME, E2E_USER, OWNER_STORAGE_STATE_PATH, } from "./setup/test-helpers"; @@ -149,7 +149,8 @@ test.describe("delegated writes", () => { const banner = page.locator('[data-slot="shared-record-banner"]'); await expect(banner).toBeVisible(); - await expect(banner).toContainText(E2E_OWNER.username); + // v1.37.2 — the owner set a full name, so the banner names them by it. + await expect(banner).toContainText(E2E_OWNER_FULL_NAME); // 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"); }); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 981a55c33..3619bfaa7 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -65,6 +65,12 @@ export const E2E_OWNER = { role: "USER", } as const; +/** + * v1.37.2 — the owner carries a full name, so the switcher journey can assert + * that a delegate sees it. Generic on purpose: fixtures hold no real name. + */ +export const E2E_OWNER_FULL_NAME = "Test Full Name"; + /** * v1.37.0 — the account that creates and administers managed profiles. * @@ -592,20 +598,22 @@ export default async function globalSetup(config: FullConfig): Promise { `INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, - onboarding_completed_at, onboarding_tour_completed) - VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + onboarding_completed_at, onboarding_tour_completed, full_name) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, - onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + full_name = EXCLUDED.full_name`, [ cuid(), E2E_OWNER.username, E2E_OWNER.email, await hashPassword(E2E_OWNER.password), now, + E2E_OWNER_FULL_NAME, ], ); diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9b075c908..9fd153d33 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -9,6 +9,7 @@ export { E2E_USER, E2E_OWNER, + E2E_OWNER_FULL_NAME, E2E_GUARDIAN, E2E_SCOPE_DELEGATE, E2E_SCOPE_RECORDS, diff --git a/src/app/medications/__tests__/take-all-due-affordance.test.tsx b/src/app/medications/__tests__/take-all-due-affordance.test.tsx index 1454d53b5..0a9407b01 100644 --- a/src/app/medications/__tests__/take-all-due-affordance.test.tsx +++ b/src/app/medications/__tests__/take-all-due-affordance.test.tsx @@ -53,6 +53,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index a7a5ec68b..c8a14fd45 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -36,6 +36,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index 8eca455f6..62d6d7af9 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -7,6 +7,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "write" as const, level: "write" as const, sections: null, diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx index ba87832d5..7757c8e32 100644 --- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx +++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx @@ -132,6 +132,7 @@ const OWNER_READ: AccountAccessEntry = { accountId: "acct-owner", username: "grandma", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, @@ -148,6 +149,7 @@ const MANAGED_GUARDIAN: AccountAccessEntry = { accountId: "managed-record", username: "managed-record", displayName: "Managed profile", + fullName: null, access: "write" as const, level: "manage" as const, sections: null, diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx index 53a2049f2..cfed8bb74 100644 --- a/src/components/layout/__tests__/shared-record-chrome.test.tsx +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -56,6 +56,7 @@ const OWNER = { accountId: "acct-owner", username: "grandma", displayName: "Margarethe", + fullName: null, access: "read" as const, level: "read" as const, sections: null, @@ -108,6 +109,25 @@ describe("", () => { expect(html).toContain("grandma"); }); + it("names the owner in full when they have a full name, to a read-only delegate", () => { + // v1.37.2 — the banner is the account-access view that renders server-side, + // and this OWNER is READ (`canWrite: false`). The full name leads over the + // greeting name: a delegate about to log a reading sees whose record it is + // as who they are, not by a nickname. A deliberate disclosure to everyone + // shared with, read-only included (maintainer decision 2026-08-08). + const named = { + ...OWNER, + fullName: "Test Full Name", + displayName: "Margarethe", + }; + const html = render( + { accounts: [named], active: named, canSwitch: true }, + , + ); + expect(html).toContain('data-slot="shared-record-banner"'); + expect(html).toContain("Test Full Name"); + }); + it("says the access is read-only, and carries the way out", () => { const html = render( { accounts: [OWNER], active: OWNER, canSwitch: true }, diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index f75482bf7..58c07f1c3 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -213,6 +213,7 @@ function entry(partial: Partial = {}): AccountAccessEntry { accountId: "p1", username: "managed-abc", displayName: "Managed record", + fullName: null, access: "write", level: "manage", recordKind: "managed", diff --git a/src/hooks/__tests__/use-auth-account-access-state.test.ts b/src/hooks/__tests__/use-auth-account-access-state.test.ts index b9ec29a73..cabb5bad1 100644 --- a/src/hooks/__tests__/use-auth-account-access-state.test.ts +++ b/src/hooks/__tests__/use-auth-account-access-state.test.ts @@ -31,6 +31,7 @@ const canonicalEntry = { accountId: "record-a", username: "record-a", displayName: "Record A", + fullName: null, access: "read", level: "read", recordKind: "shared", diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index 256e3c487..4a7609b06 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -27,6 +27,7 @@ const READ_ONLY: AccountAccessEntry = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "read", level: "read", sections: null, diff --git a/src/lib/insights/__tests__/coach-launch-context.test.tsx b/src/lib/insights/__tests__/coach-launch-context.test.tsx index b294d8ad7..9e94ce003 100644 --- a/src/lib/insights/__tests__/coach-launch-context.test.tsx +++ b/src/lib/insights/__tests__/coach-launch-context.test.tsx @@ -29,6 +29,7 @@ const OWNER = { accountId: "acct-owner", username: "owner", displayName: "Margarethe", + fullName: null, access: "write" as const, level: "write" as const, sections: null, diff --git a/src/lib/sharing/__tests__/account-access-schema.test.ts b/src/lib/sharing/__tests__/account-access-schema.test.ts index 69db679b9..a702767ad 100644 --- a/src/lib/sharing/__tests__/account-access-schema.test.ts +++ b/src/lib/sharing/__tests__/account-access-schema.test.ts @@ -6,6 +6,7 @@ const sharedEntry = { accountId: "record-a", username: "record-a", displayName: "Record A", + fullName: "Test Full Name", access: "read", level: "read", recordKind: "shared", @@ -73,6 +74,11 @@ describe("parseAccountAccess", () => { ], ["username", { username: "different-record" }, {}], ["display name", { displayName: "Different record" }, {}], + // v1.37.2 — the owner's full name is part of the entry now, so the two + // published views of the active record must agree on it like every other + // field. A parity matcher blind to it would let the banner and the + // switcher name the same owner differently. + ["full name", { fullName: "Different name" }, {}], ])( "fails closed when active disagrees with its canonical entry on %s", (_field, activeOverrides, blockOverrides) => { diff --git a/src/lib/sharing/__tests__/account-access.test.ts b/src/lib/sharing/__tests__/account-access.test.ts index a1b89c734..3fc48b80a 100644 --- a/src/lib/sharing/__tests__/account-access.test.ts +++ b/src/lib/sharing/__tests__/account-access.test.ts @@ -31,6 +31,7 @@ function activeGrant({ id: "record-owner", username: "record-owner", displayName: "Record owner", + fullName: "Test Full Name", managedProfileAt, }, }; @@ -93,6 +94,31 @@ describe("resolveAccountAccess", () => { expect(access.recordKind).toBe("self"); }); + it("carries the owner's full name to a read-only delegate", async () => { + // v1.37.2 — the record owner's full name crosses to whoever they share + // with, read-only included (maintainer decision 2026-08-08). It is the + // resolver that lets it cross, so it is the resolver that proves it: a + // READ grant, and the delegate's resolved entry still names the owner in + // full. If a future change drops `fullName` from the grantor `select`, + // this fails rather than silently reverting the disclosure to a nickname. + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ access: "READ" }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: {}, + }); + + expect(access.accounts[0]).toEqual( + expect.objectContaining({ + access: "read", + canWrite: false, + fullName: "Test Full Name", + }), + ); + }); + it("keeps whole-record legacy payloads decodable and scoped payloads closed", () => { for (const fixture of LEGACY_ACCOUNT_PAYLOADS) { const decoded = decodeLegacyWholeRecordAccess(fixture); diff --git a/src/lib/sharing/__tests__/account-label.test.ts b/src/lib/sharing/__tests__/account-label.test.ts new file mode 100644 index 000000000..485df1d87 --- /dev/null +++ b/src/lib/sharing/__tests__/account-label.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { accountLabel } from "../account-access-view"; + +/** + * `accountLabel` is the one place the banner, the switcher and the sharing + * panel resolve what to call a person, so the fallback order lives here and + * is pinned here. + * + * v1.37.2 — the record owner's full name leads, ahead of the greeting name. + * It is patient-identity data shown to everyone they share with, read-only + * included (maintainer decision 2026-08-08); the switcher and the banner pass + * the account-access entry that carries it, while the grant-view and actor + * shapes that never had it omit the argument and keep the old behaviour. + */ +describe("accountLabel", () => { + it("prefers the full name when it is set", () => { + expect( + accountLabel({ + fullName: "Test Full Name", + displayName: "Testy", + username: "test-user", + }), + ).toBe("Test Full Name"); + }); + + it("falls back to the display name when there is no full name", () => { + expect( + accountLabel({ + fullName: null, + displayName: "Testy", + username: "test-user", + }), + ).toBe("Testy"); + // A blank full name is not a name — trimmed to empty, it falls through. + expect( + accountLabel({ + fullName: " ", + displayName: "Testy", + username: "test-user", + }), + ).toBe("Testy"); + }); + + it("falls back to the username when neither name is set", () => { + expect( + accountLabel({ + fullName: null, + displayName: null, + username: "test-user", + }), + ).toBe("test-user"); + }); + + it("keeps the old two-field behaviour when no full name is passed", () => { + // The grant-view and actor shapes never carried a full name; omitting the + // argument must read exactly as it did before the field existed. + expect(accountLabel({ displayName: "Testy", username: "test-user" })).toBe( + "Testy", + ); + expect(accountLabel({ displayName: null, username: "test-user" })).toBe( + "test-user", + ); + }); +}); diff --git a/src/lib/sharing/account-access-schema.ts b/src/lib/sharing/account-access-schema.ts index 72faf24e2..779afc84b 100644 --- a/src/lib/sharing/account-access-schema.ts +++ b/src/lib/sharing/account-access-schema.ts @@ -13,6 +13,10 @@ export const accountAccessEntrySchema = z accountId: z.string().min(1), username: z.string().min(1), displayName: z.string().nullable(), + // v1.37.2 — the record owner's full name, shown in front of a delegate on + // the switcher + banner. A deliberate disclosure to everyone they share + // with; see `accountLabel`. + fullName: z.string().nullable(), access: z.enum(["read", "write"]), level: accountAccessLevelSchema, recordKind: accountRecordKindSchema, @@ -80,6 +84,7 @@ function matchesCanonicalAccountAccessEntry( active.accountId === canonical.accountId && active.username === canonical.username && active.displayName === canonical.displayName && + active.fullName === canonical.fullName && active.access === canonical.access && active.level === canonical.level && active.recordKind === canonical.recordKind && diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 39b020583..23c360bf9 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -50,6 +50,14 @@ export interface AccountAccessEntry { accountId: string; username: string; displayName: string | null; + /** + * v1.37.2 — the record owner's full name, when they have set one. Shown in + * front of a delegate on the switcher and the banner, ahead of the greeting + * name, so the person whose record they are entering reads as who they are + * rather than by a nickname. See {@link accountLabel} for the deliberate + * disclosure this carries; it is a maintainer decision, not an oversight. + */ + fullName: string | null; /** * The grant's level, as the owner offered it and the caller accepted it. * @@ -116,16 +124,29 @@ export interface AccountAccess { /** * The name to put in front of a person, given what the payload published. * - * `displayName` when the account set one, the username otherwise. This is a - * presentation fallback and not a derivation of anything the server decided — - * both values are published, and which of the two reads better is the client's - * business. Kept here so the banner, the switcher and the sharing panel cannot - * disagree about what to call somebody. + * `fullName` when it is set, `displayName` next, the username last. All three + * are published; which reads best is the client's business, not a derivation + * of anything the server decided. Kept here so the banner, the switcher and + * the sharing panel cannot disagree about what to call somebody. + * + * `fullName` is optional on the argument on purpose: only the account-access + * entry carries it, so the switcher and the banner prefer it while the + * grant-view and actor shapes that never had it keep falling back to the + * greeting name exactly as before. + * + * v1.37.2 — the record owner's full name is shown to anyone they share with, + * read-only included; maintainer decision 2026-08-08. It is patient-identity + * data that until now stayed behind a MANAGE-gated export, so a future reader + * should not "tighten" this back as a leak: it is the intended behaviour, not + * an accident of the label falling through to it. */ export function accountLabel(entry: { displayName: string | null; username: string; + fullName?: string | null; }): string { + const full = entry.fullName?.trim(); + if (full && full.length > 0) return full; const named = entry.displayName?.trim(); return named && named.length > 0 ? named : entry.username; } diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 019e80da0..0ce051611 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -109,6 +109,10 @@ export async function resolveAccountAccess(auth: { id: true, username: true, displayName: true, + // v1.37.2 — shown in front of a delegate on the switcher + banner, + // ahead of the greeting name. A deliberate disclosure of the owner's + // full name to everyone they share with; see `accountLabel`. + fullName: true, managedProfileAt: true, }, }, @@ -126,6 +130,7 @@ export async function resolveAccountAccess(auth: { accountId: grant.grantor.id, username: grant.grantor.username, displayName: grant.grantor.displayName, + fullName: grant.grantor.fullName, access: level === "read" ? "read" : "write", level, recordKind: From 650ac6189ca51b6cba5d41cc5564cf6e195abf42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:07:34 +0200 Subject: [PATCH 12/12] docs: note the owner full name and the ECG teaser cap under Unreleased --- CHANGELOG.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13a2a9fb..0388fd56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,15 +99,20 @@ before showing a single recording, two of them saying the same thing. They are gone; the recording's own page still states that the result shown is the device's. The device's verdict now rides the timestamp as a tag instead - of taking a line of its own, the list shows the five most recent strips - rather than every one ever recorded, and a recording has its own address - with the back link above the heading, so a strip can be linked to and the - browser's own Back works. + of taking a line of its own. The overview teaser shows the five most recent + strips rather than every one ever recorded, while the ECG page itself still + lists them all, so nothing is hidden where you go to look. A recording has + its own address now, with the back link above the heading, so a strip can be + linked to and the browser's own Back works. - An entry in the record switcher no longer carries the access level on a line of its own. In a menu that narrow it was truncated more often than it was read, and the banner states it in full once you are inside the record. +- The record owner's name reads in full on the switcher and the banner when + they have set one, ahead of the greeting name — so a delegate entering + somebody's record sees whose it is by who they are. + ## [1.37.1] — 2026-08-08 ### Added