Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 17:18:53 +02:00
commit c82ed69549
178 files changed
+19476 -15057

No files matched your search

+62 -1
View File
@@ -11,6 +11,7 @@ is about choosing and writing tests.
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
@@ -27,6 +28,28 @@ ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## Critical journeys
A few e2e journeys carry `@critical` in their test titles: signing in with a
password and a passkey and signing out, a reading reaching the dashboard, a
dose moving compliance, a mood entry, a lab CSV import, a questionnaire in
German and Brazilian Portuguese, a setting that persists, a backup coming
back, sharing a record, and the offline page. `pnpm e2e:critical` runs them.
They run against the real server and a seeded Postgres. A spec file that
carries the tag may not intercept anything under `/api`, directly or through
a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts`
refuses it and keeps a floor on the number of tagged tests. Stubbing an
unreachable third-party host that is not the journey's subject is allowed,
with the reason next to the call.
A journey that writes gets an account nobody else writes. The accounts in
`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey
and per Chromium project, so a journey runs at both widths without the two
projects meeting. Reset what a previous run left with `resetCriticalAccount`
in a `beforeEach`, and read results through the app's own routes rather than
through the database.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
@@ -114,6 +137,43 @@ into a fresh account and reads it back there. A new setting column without a
table entry fails the file. A column with no write route says why in its
entry.
## Browser tests
Interaction belongs in the `browser` project: `*.browser.test.tsx` files run
in headless Chromium through Vitest's Playwright provider, as part of
`pnpm test`. Run them alone with `pnpm test --project browser`, or one file
with `pnpm exec vitest run --project browser <path>`. The Node projects never
collect these files and this project collects nothing else. CI installs the
browser it needs (`playwright install --only-shell chromium`); locally, run
that once.
Reach for it when the claim is about behaviour a markup test cannot see:
focus, keys, an open menu, a pending state, the body a form posts, the URL a
filter writes. A `renderToStaticMarkup` grep stays the tool for what a given
state renders, and bottom-sheet gestures stay in `e2e/`.
The helper is `tests/browser/`:
- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real
TanStack `QueryClient` (no retries) and `I18nProvider` with the real
catalog. The signed-in account is seeded where `useAuth` reads it, with
`authUser()` defaults under `user`. The app stylesheet and the default
theme class are loaded, so layout, visibility and colour tokens resolve.
- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with
`.on(method, path, { status, data, error, meta })`, hold one with
`.deferred(...)` to assert a pending state, and read the parsed bodies from
`.calls(method, path)`. An unmatched request gets a 404 envelope and is
recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`.
- `next/navigation` is aliased to a shim over the page's real `location` and
`history`; `url.ts` sets the starting query, records pushes and replaces,
and simulates Back or an edited address.
- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run.
Every test gets the real `fetch`, clock, address and an empty `localStorage`
back afterwards. Locate by role and accessible name, the way a person finds a
control; a `Label` adds a colon to its name, so match a labelled combobox with
`/^Type/` rather than `"Type"`.
## The integration database
Global setup boots one Postgres container and migrates one template database.
@@ -152,7 +212,8 @@ node scripts/test-durations.mjs unit unit.json
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
regenerated from CI timings, which is the better source.
The unit suite is three projects, decided per file from its source
The unit suite is three Node projects and the `browser` project above. The
Node ones are decided per file from its source
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
+1 -1
View File
@@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
+226 -42
View File
@@ -18,7 +18,11 @@
* 5. what was deleted is back under its ORIGINAL id, and what arrived after
* the snapshot is gone — which is what "the restore replaces the
* account's data tables" in `docs/ops/backup-restore.md` means when it is
* spelled out.
* spelled out;
* 6. a second person downloads their own full backup, their account is
* recreated empty under the same id (the runbook's new host), the admin
* uploads the file and restores it, and the person signs in to the same
* rows under the same ids.
*
* Refusing controls, because a restore path that cannot say no is worse than
* none: a stored copy with one flipped ciphertext byte is refused and changes
@@ -36,19 +40,20 @@
*
* Everything the journey does, it does through the app: the readings and the
* document ride the page's own `fetch`, the archive and the snapshot ride the
* real buttons, the restore rides the typed-confirmation dialog. The only two
* real buttons, the restore rides the typed-confirmation dialog. The only
* things reaching Postgres directly are the ones a browser cannot express —
* resetting the account between repetitions, and corrupting one stored copy so
* the authenticated envelope has something to refuse. Both live in
* `setup/backup-journey-fixture.ts`.
* resetting the accounts between repetitions, corrupting one stored copy so
* the authenticated envelope has something to refuse (both in
* `setup/backup-journey-fixture.ts`), and recreating the second account under
* its own id (`setup/critical-fixture.ts`).
*
* Serial and single-project: the four tests are four acts of one journey, they
* mutate the account destructively, and the last one moves a session's record
* Serial and single-project: the five tests are five acts of one journey, they
* mutate the accounts destructively, and the last one moves a session's record
* selector. Its jar is its own for the reason `global-setup.ts` spells out.
*/
import { readFile } from "node:fs/promises";
import type { Page } from "@playwright/test";
import type { Page, Response } from "@playwright/test";
import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive";
@@ -65,6 +70,13 @@ import {
storedMeasurements,
storeTamperedCopy,
} from "./setup/backup-journey-fixture";
import {
accountRowCounts,
clearLoginBuckets,
criticalAccount,
recreateCriticalAccount,
resetCriticalAccount,
} from "./setup/critical-fixture";
import {
actWithReproof,
completeReproofWithPassword,
@@ -91,6 +103,8 @@ const RESTORED_WEIGHT_KG = 81.4;
const UNTOUCHED_PULSE_BPM = 58;
/** The reading that arrives AFTER the snapshot and must not survive it. */
const POST_SNAPSHOT_PULSE_BPM = 66;
/** The second account's reading, carried through its own backup. */
const FRESH_ACCOUNT_WEIGHT_KG = 67.3;
const MEDICATION_NAME = "E2E Backup Tablet";
@@ -291,6 +305,45 @@ async function restoreThroughDialog(page: Page, id: string) {
return answered;
}
/**
* Wait for a queued restore to finish, and fail with its code if it failed.
*
* The restore runs on the `backup-restore` queue: the request answers 202
* with the job and its status route, and the worker inside the server under
* test does the work. Nothing after this is read until the job says it is
* done, so every assertion is about the finished restore rather than a moment
* in the middle of it.
*/
async function awaitRestore(page: Page, response: Response): Promise<void> {
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead of
// running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: 60_000, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
}
test.describe("Backup and restore, through the settings surfaces", () => {
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
@@ -319,7 +372,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
tamperedCopyId = null;
});
test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({
test("a record is written, sealed into an HLX1 archive, and snapshotted @critical", async ({
page,
}) => {
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
@@ -505,7 +558,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(await storedMeasurements()).toEqual(before);
});
test("the snapshot puts back what was deleted and drops what came after", async ({
test("the snapshot puts back what was deleted and drops what came after @critical", async ({
page,
}) => {
const record = requireSeeded();
@@ -563,39 +616,10 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(beforeRestore.map((m) => m.id)).toContain(laterId);
await openBackupsConsole(page);
const response = await restoreThroughDialog(page, requireSnapshot());
// The restore runs on the `backup-restore` queue: the request answers 202
// with the job and its status route, and the worker inside the server
// under test does the work. Nothing below is read until the job says it
// is done, so every assertion is about the finished restore rather than
// a moment in the middle of it.
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
await awaitRestore(
page,
await restoreThroughDialog(page, requireSnapshot()),
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead
// of running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: 60_000, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
const afterRestore = await storedMeasurements();
@@ -650,6 +674,166 @@ test.describe("Backup and restore, through the settings surfaces", () => {
),
).toHaveCount(0);
});
test("a full backup the operator restores brings the record back into a fresh account @critical", async ({
page,
browser,
}, testInfo) => {
// The second account: a person with a record of their own, who downloads
// the file the export card offers. Its jar is its own; the act below
// gives it a session that needs a re-proof, like the admin's.
const person = criticalAccount("restore", testInfo);
await resetCriticalAccount(person, [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
"DELETE FROM rate_limits WHERE key = 'export:' || $1",
]);
const context = await browser.newContext({
storageState: person.storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
let backupPath = "";
let written: { weightId: string; moodId: string };
try {
const own = await context.newPage();
const endOwnSession = await useStaleSession(own, person.username);
try {
await own.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(own.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
timeout: 30_000,
});
written = await own.evaluate(async (weightKg: number) => {
const post = async (path: string, body: unknown) => {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json()) as { data: { id: string } | null };
if (!res.ok || !json.data) {
throw new Error(`${path} answered ${res.status}`);
}
return json.data.id;
};
const at = new Date(Date.now() - 90 * 60_000).toISOString();
return {
weightId: await post("/api/measurements", {
type: "WEIGHT",
value: weightKg,
measuredAt: at,
}),
moodId: await post("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: at,
}),
};
}, FRESH_ACCOUNT_WEIGHT_KG);
await own.goto("/settings/export", { waitUntil: "domcontentloaded" });
await expect(own.getByTestId("export-card-full-backup")).toBeVisible({
timeout: 30_000,
});
const download = own.waitForEvent("download");
await actWithReproof(own, person.password, () =>
own.getByTestId("export-action-full-backup").click(),
);
// Kept past the context: a download's own file goes with it.
backupPath = testInfo.outputPath("full-backup.json");
await (await download).saveAs(backupPath);
} finally {
await endOwnSession();
}
} finally {
await context.close();
}
// ── the account is gone, and comes back empty under the same id ──────
// What a new host is to the runbook (`docs/ops/backup-restore.md`): the
// backup names its owner by id, so the account is recreated under it
// before the upload. A browser cannot express this; it is the one act
// here that goes to Postgres.
await recreateCriticalAccount(person);
expect(await accountRowCounts(person)).toEqual({
measurements: 0,
moodEntries: 0,
});
// ── the operator uploads the file and restores it ───────────────────
await openBackupsConsole(page);
const uploaded = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/admin/backups/upload" &&
res.request().method() === "POST" &&
res.status() !== 401,
);
await actWithReproof(page, E2E_BACKUP_ADMIN.password, () =>
page
.locator('input[type="file"][accept*="application/gzip"]')
.setInputFiles(backupPath),
);
const upload = await uploaded;
expect(upload.status(), (await upload.text()).slice(0, 400)).toBe(201);
const uploadId = ((await upload.json()) as { data: { id: string } }).data
.id;
await expect(snapshotRow(page, uploadId)).toHaveCount(1, {
timeout: 30_000,
});
await awaitRestore(page, await restoreThroughDialog(page, uploadId));
// ── and the person signs in to their record ──────────────────────────
const back = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
});
try {
await clearLoginBuckets();
const login = await back.request.post("/api/auth/login", {
data: { email: person.username, password: person.password },
});
expect(login.status()).toBe(200);
const signedIn = await back.newPage();
// The same rows, under their own ids: a restore, not a re-entry.
const measurements = (
(await (await signedIn.request.get("/api/measurements")).json()) as {
data: { measurements: Array<{ id: string; value: number }> };
}
).data.measurements;
expect(measurements).toEqual([
expect.objectContaining({
id: written.weightId,
value: FRESH_ACCOUNT_WEIGHT_KG,
}),
]);
const moods = (
(await (await signedIn.request.get("/api/mood-entries")).json()) as {
data: { entries: Array<{ id: string; mood: string }> };
}
).data.entries;
expect(moods).toEqual([
expect.objectContaining({ id: written.moodId, mood: "OKAY" }),
]);
await signedIn.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(
signedIn
.locator(
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
`[data-measurement-value="${FRESH_ACCOUNT_WEIGHT_KG}"]`,
)
.first(),
).toBeVisible({ timeout: 30_000 });
} finally {
await back.close();
}
// The admin's own record is untouched by a restore aimed at somebody else.
expect(
(await storedMeasurements()).filter((m) => m.deletedAt === null),
).toHaveLength(2);
});
});
test.describe("A read-level delegate inside the record", () => {
+7 -3
View File
@@ -136,7 +136,7 @@ test.describe("delegated writes", () => {
).toBeVisible();
}
test("the owner invites at a level that may add entries", async () => {
test("the owner invites at a level that may add entries @critical", async () => {
await openInviteForm();
const identifier = ownerPage.locator(
@@ -183,7 +183,9 @@ test.describe("delegated writes", () => {
).toBe(WRITE_LEVEL_VALUE);
});
test("the delegate accepts and opens the record", async ({ page }) => {
test("the delegate accepts and opens the record @critical", async ({
page,
}) => {
await page.goto("/settings/access");
await page.locator('[data-slot="grant-accept"]').first().click();
@@ -203,7 +205,9 @@ test.describe("delegated writes", () => {
await expect(banner).not.toContainText("read it, not change it");
});
test("the reading they add lands in the owner's record", async ({ page }) => {
test("the reading they add lands in the owner's record @critical", async ({
page,
}) => {
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
+231 -47
View File
@@ -1,24 +1,89 @@
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
clearLoginBuckets,
criticalAccount,
resetCriticalAccount,
type CriticalAccount,
} from "./setup/critical-fixture";
/**
* Smoke checks on the login surface — must render, must wire the
* password-manager autofill hints (autoComplete="username" /
* "current-password"), must let the user submit.
* Signing in and out, against the real server.
*
* These do NOT log in (no DB seed required); the password-flow
* roundtrip is exercised by the integration test in
* tests/integration/auth-flow.test.ts.
* Every test starts without a session and signs in through the form, so what
* it reads is what the login route, the session table and the shell did, not
* a payload typed into the test. The account is the sign-in journey's own
* (one per project, see `critical-fixture.ts`): it gets no jar from global
* setup, because the tests here end the sessions they open and a jar captured
* once would be dead for a retry.
*
* The passkey half runs through Chromium's virtual authenticator, attached
* over CDP. It is a real authenticator as far as the page and the server are
* concerned: the registration and the assertion are signed, and the server
* verifies both against the origin and RP id the suite runs under.
*/
async function openPasswordForm(page: import("@playwright/test").Page) {
test.use({ storageState: { cookies: [], origins: [] } });
const SESSION_COOKIE = "healthlog_session";
async function openPasswordForm(page: Page): Promise<void> {
await page.goto("/auth/login");
// The login page defaults to passkey mode; the password form is
// mounted only after clicking the "Login with password" / "Mit
// Passwort anmelden" outline button.
await page
.getByRole("button", {
name: /sign in with password|login with password|mit passwort/i,
})
.click();
// The login page opens in passkey mode; the password form mounts after
// this button. The button is server-rendered, so the click is retried
// until the form it mounts is there.
const usePassword = page.getByTestId("login-use-password");
const email = page.getByTestId("login-email");
await expect(async () => {
await usePassword.click({ timeout: 2_000 });
await expect(email).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
}
async function signInWithPassword(
page: Page,
account: CriticalAccount,
): Promise<void> {
await openPasswordForm(page);
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
// The login route allows five attempts per IP per quarter hour and the
// whole suite is one IP; see `clearLoginBuckets`.
await clearLoginBuckets();
await page.getByTestId("login-password-submit").click();
await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 });
}
async function sessionCookie(context: BrowserContext): Promise<string> {
const cookie = (await context.cookies()).find(
(c) => c.name === SESSION_COOKIE,
);
expect(cookie, "the login answered without a session cookie").toBeTruthy();
return cookie!.value;
}
/** The username `/api/auth/me` answers for this context's session. */
async function signedInAs(page: Page): Promise<string | null> {
const res = await page.request.get("/api/auth/me");
if (res.status() !== 200) return null;
return ((await res.json()) as { data: { username: string } }).data.username;
}
async function signOutThroughTheMenu(page: Page): Promise<void> {
// The desktop sidebar and the phone top bar each carry a user menu; only
// one of them is on screen at a given width.
const trigger = page
.getByRole("button", { name: "User menu" })
.filter({ visible: true })
.first();
const signOut = page.getByRole("menuitem", { name: "Sign out" });
await expect(async () => {
await trigger.click({ timeout: 2_000 });
await expect(signOut).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
await signOut.click();
await page.waitForURL((url) => url.pathname === "/auth/login");
}
test.describe("login page", () => {
@@ -27,47 +92,166 @@ test.describe("login page", () => {
}) => {
await openPasswordForm(page);
const username = page
.getByLabel(/username|benutzername|email|e-mail/i)
.first();
const username = page.getByTestId("login-email");
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);
const password = page.locator('input[type="password"]').first();
const password = page.getByTestId("login-password");
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});
test("rejects an obviously-wrong credential pair", async ({ page }) => {
test("refuses a wrong password and opens no session", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await openPasswordForm(page);
await page
.getByLabel(/username|benutzername|email|e-mail/i)
.first()
.fill("nobody-here");
await page.locator('input[type="password"]').first().fill("not-the-pw");
// Intercept the API call so the test does not depend on a real
// backend rejecting the credentials. The login form's error
// surfacing is what we actually want to prove here.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill("not-this-account-password");
await clearLoginBuckets();
const answer = page.waitForResponse(
(res) =>
res.url().endsWith("/api/auth/login") &&
res.request().method() === "POST",
);
await page.getByTestId("login-password-submit").click();
// The form has multiple buttons matching /sign in/ — both the
// passkey CTA and the password submit. Filter to the submit button
// so the click lands on the right one.
await page
.locator('button[type="submit"]')
.filter({ hasText: /login|anmelden|sign in/i })
.click();
await expect(
page.getByText(/invalid credentials|ungültig|falsch/i),
).toBeVisible({ timeout: 5_000 });
expect((await answer).status()).toBe(401);
await expect(page.getByText("Invalid credentials")).toBeVisible();
await expect(page).toHaveURL(/\/auth\/login/);
expect(
(await context.cookies()).some((c) => c.name === SESSION_COOKIE),
).toBe(false);
});
});
test.describe("signing in and out", () => {
test("a password sign-in lands on the dashboard as that account @critical", async ({
page,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
await expect(page.locator("main")).toBeVisible();
expect(await signedInAs(page)).toBe(account.username);
});
test("signing out ends the session on the server, not only in the browser @critical", async ({
page,
context,
playwright,
baseURL,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
const cookie = await sessionCookie(context);
// A second client holding the same cookie, the way a copied jar or a
// stolen cookie would.
const replay = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { cookie: `${SESSION_COOKIE}=${cookie}` },
});
try {
// Positive control: the replayed cookie is a working session before
// the sign-out, so the 401 below is about the sign-out.
expect((await replay.get("/api/auth/me")).status()).toBe(200);
await signOutThroughTheMenu(page);
// The browser dropped its cookie; that alone would pass with a server
// that kept the row. The proof is the old cookie being refused.
expect(await signedInAs(page)).toBeNull();
expect((await replay.get("/api/auth/me")).status()).toBe(401);
expect((await replay.get("/api/measurements?limit=1")).status()).toBe(
401,
);
} finally {
await replay.dispose();
}
// And the protected shell sends the browser back to the form.
await page.goto("/");
await page.waitForURL((url) => url.pathname === "/auth/login");
});
test("a passkey registered in settings signs the account back in @critical", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
// A retry, or the last run, may have left a credential on the account;
// the list below has to count only the one this test registers.
await resetCriticalAccount(account, [
"DELETE FROM passkeys WHERE user_id = $1",
]);
const cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send(
"WebAuthn.addVirtualAuthenticator",
{
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
},
);
try {
await signInWithPassword(page, account);
await test.step("register a passkey under the current password", async () => {
await page.goto("/settings/security");
await page.getByRole("button", { name: "Add passkey" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await dialog.locator('input[type="password"]').fill(account.password);
await dialog.getByRole("button", { name: "Verify and add" }).click();
await expect(dialog).toBeHidden();
await expect(
page.getByTestId("passkeys-list").locator("li"),
).toHaveCount(1);
const stored = await cdp.send("WebAuthn.getCredentials", {
authenticatorId,
});
expect(stored.credentials).toHaveLength(1);
expect(stored.credentials[0].isResidentCredential).toBe(true);
});
await signOutThroughTheMenu(page);
expect(await signedInAs(page)).toBeNull();
await test.step("sign in with it", async () => {
await clearLoginBuckets();
await page
.getByRole("button", { name: "Sign in with Passkey" })
.click();
await page.waitForURL((url) => url.pathname === "/", {
timeout: 30_000,
});
expect(await signedInAs(page)).toBe(account.username);
// The server recorded the use against the stored credential.
const res = await page.request.get("/api/auth/passkeys");
expect(res.status()).toBe(200);
const passkeys = (
(await res.json()) as {
data: Array<{ lastUsedAt: string | null }>;
}
).data;
expect(passkeys).toHaveLength(1);
expect(passkeys[0].lastUsedAt).not.toBeNull();
});
} finally {
await cdp
.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId })
.catch(() => undefined);
}
});
});
+111 -148
View File
@@ -1,140 +1,92 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { openMenu } from "./open-menu";
/**
* Add-measurement flow — exercises the dashboard's quick-entry dropdown,
* the dialog-mounted MeasurementForm, the POST to /api/measurements, and
* the resulting query invalidation that surfaces the row on
* `/measurements`.
* Add-measurement flow, against the real server: the dashboard's quick-entry
* menu, the dialog-mounted MeasurementForm, the real POST to
* `/api/measurements`, and then every surface that has to agree about the
* reading afterwards: the dashboard tile, the weight chart (through the data
* table that lists exactly the points it drew) and the day view.
*
* Weight is the simplest type (single numeric input + datetime), so it
* keeps the spec focused on the round-trip rather than the BP three-field
* batched submit.
* Until v1.46 this spec answered its own POST and its own list read with a
* route stub, so it proved that the list renders a row the test typed in.
* Nothing here is stubbed now. The account is the measurement journey's own,
* one per project (`critical-fixture.ts`), and is emptied of readings before
* the test, so "the tile shows the weight" can only mean the weight this test
* entered.
*
* Weight is the simplest type (single numeric input + datetime), so it keeps
* the spec on the round trip rather than on the blood-pressure batch.
*/
/** Distinct from anything a fixture seeds, so a match is this reading. */
const WEIGHT_KG = "81.7";
/** Today in the browser's zone (`timezoneId` in `playwright.config.ts`). */
function todayInBerlin(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
/** The account's weight readings, as the list route answers them. */
async function storedWeights(
page: Page,
): Promise<Array<{ value: number; unit: string }>> {
const res = await page.request.get("/api/measurements?type=WEIGHT");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { measurements: Array<{ value: number; unit: string }> };
};
return data.measurements.map(({ value, unit }) => ({ value, unit }));
}
test.describe("add measurement flow", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.use({ storageState: criticalStorageState("measurement") });
// This journey lands on the dashboard, which is the most expensive page in
// the product to render cold: the route segment suspends behind the server
// render and shows `loading.tsx` until it resolves. On a shared runner with
// the whole suite in flight that has overrun the 30 s default, and the test
// then died on the quick-add trigger rather than on anything it is about.
// The subject here is the add-then-appears round trip, not the dashboard's
// cold-start latency, so the budget is the page's and the assertions stay
// exactly as tight as they were.
// the product to render cold, and then reads three more surfaces. The
// assertions stay as tight as they are; the budget is the pages'.
test.setTimeout(90_000);
test("creating a weight reading surfaces it in the list", async ({
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("measurement", testInfo), [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
]);
});
test("a weight entered on the dashboard reaches the tile, the chart and the day view @critical", async ({
page,
}) => {
// Capture the POST payload so we can assert against it independently
// of any list-fetch response Playwright might cache. Match both
// `/api/measurements` (POST creates a reading) and the list query
// `/api/measurements?type=...&limit=...&offset=...` (GET).
let postedBody: unknown = null;
await page.route("**/api/measurements*", async (route) => {
const req = route.request();
if (req.method() === "POST") {
postedBody = JSON.parse(req.postData() ?? "null");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "m_e2e_1", success: true },
error: null,
}),
});
return;
}
// GET — return the just-posted reading so the list view shows it.
// The list expects `data.measurements: Measurement[]` plus
// `data.meta.total`; chart fetches use the same envelope.
const body = postedBody as {
type?: string;
value?: number;
measuredAt?: string;
} | null;
// The list-page `Measurement` shape requires `unit` and `source`
// — without them the row crashes the render and the e2e poll
// never sees "78.4". Mock with the same fields a real reading
// carries.
const measurements = body
? [
{
id: "m_e2e_1",
type: body.type,
value: body.value,
unit: body.type === "WEIGHT" ? "kg" : "",
source: "MANUAL",
measuredAt: body.measuredAt,
notes: null,
},
]
: [];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
// Dashboard's analytics fetch — empty summaries are fine; we only
// need the page to render so we can click "Add".
//
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The v1.4.39.2 dashboard split fires both
// requests in parallel; the previous string glob missed the slim
// variant which then fell through to the real route and slowed
// the page paint enough that the post-mutation list query never
// surfaced inside the 10 s window.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
// at the top-right of `<main>`. Scope the locator there so we don't
// accidentally match an "Add" button on the sidebar.
const main = page.locator("main");
const addTrigger = main
// The quick-entry trigger sits at the top of `<main>`. Waiting for it,
// rather than for the route skeleton to go, is waiting for the thing the
// next line clicks.
const addTrigger = page
.locator("main")
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
.first();
// Wait for the trigger itself rather than for the route-level skeleton to
// go. The skeleton is also absent while the protected shell holds the
// whole page behind its hydration gate, so its disappearance is not a
// statement about the suspended segment having resolved; the control the
// next line clicks is. `networkidle` says nothing about either.
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
await page.waitForLoadState("networkidle");
// The account holds no weight yet, so whatever the surfaces below show
// can only come from this reading.
expect(await storedWeights(page)).toEqual([]);
await openMenu(page, addTrigger);
// v1.5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
// so we can target it directly.
// The desktop rail opens a dropdown, the phone shell the capture sheet
// the bottom nav's plus opens; the measurement entry is in either.
await page
@@ -143,52 +95,63 @@ test.describe("add measurement flow", () => {
.first()
.click();
// Form should be visible inside the dialog. Switch type to WEIGHT
// (combo-box defaults to BLOOD_PRESSURE).
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// The Select trigger renders the current label — click to open and
// pick the weight option.
// The type select defaults to blood pressure.
await dialog.getByRole("combobox").first().click();
await page
.getByRole("option", { name: /weight|gewicht/i })
.first()
.click();
await dialog.locator("#value").fill(WEIGHT_KG);
// Weight is single-value mode — fill the value field with 78.4 kg.
const valueInput = dialog.locator("#value");
await valueInput.fill("78.4");
// Save
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/measurements" &&
res.request().method() === "POST",
);
await dialog.getByRole("button", { name: /save|speichern/i }).click();
const response = await created;
expect(response.status()).toBe(201);
await expect(dialog).toBeHidden();
// Dialog closes when the mutation succeeds
await expect(dialog).toBeHidden({ timeout: 5_000 });
// Assert the POST went through with the expected shape
expect(postedBody).toMatchObject({
type: "WEIGHT",
value: 78.4,
await test.step("the server stored exactly that reading", async () => {
expect(await storedWeights(page)).toEqual([
{ value: Number(WEIGHT_KG), unit: "kg" },
]);
});
// Navigate to the list and confirm the entry surfaces. The list
// page renders entries from /api/measurements which we've mocked
// to echo the just-posted row.
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
await test.step("the dashboard tile", async () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const tile = page.locator(
'[data-slot="dashboard-tile-link"][data-tile-id="weight"]',
);
await expect(tile).toBeVisible({ timeout: 60_000 });
await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText(
WEIGHT_KG,
);
});
// The list renders a desktop table AND a mobile card grid (one is
// hidden via `md:hidden`/`hidden md:block`). At least one match
// means our row landed in the DOM regardless of viewport.
await expect
.poll(
async () =>
await page
.locator("main")
.getByText(/78[.,]4/)
.count(),
{ timeout: 10_000 },
)
.toBeGreaterThanOrEqual(1);
await test.step("the weight chart", async () => {
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
const table = page.locator('[data-slot="chart-data-table"]').first();
await expect(table).toBeVisible({ timeout: 30_000 });
await table.locator('[data-slot="chart-data-table-toggle"]').click();
const rows = table.locator('[data-slot="chart-data-table-row"]');
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText(WEIGHT_KG);
});
await test.step("the day view", async () => {
await page.goto(`/measurements?day=${todayInBerlin()}`, {
waitUntil: "domcontentloaded",
});
const value = page.locator(
'[data-slot="day-view"] [data-slot="day-value"][data-type="WEIGHT"]',
);
await expect(value).toBeVisible({ timeout: 30_000 });
await expect(value).toContainText(WEIGHT_KG);
});
});
});
+1 -1
View File
@@ -363,7 +363,7 @@ test.describe("medication adherence journey", () => {
});
});
test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({
test("a taken and a skipped dose move the card, the history and the dashboard tile @critical", async ({
page,
}) => {
// The wizard is a seven-step dialog with per-step validation gating Next;
+188 -2
View File
@@ -2,6 +2,11 @@ import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
@@ -37,9 +42,16 @@ const CASES: readonly Case[] = [
async function expectNoSeriousViolations(page: Page, label: string) {
// The pointer rests where the last tap landed; a hover tint under it is
// not what the screen shows at rest.
// not what the screen shows at rest. Moving it away starts the tint's
// transition back, so the scan waits for every transition to settle rather
// than for a guessed number of milliseconds. Transitions only: a spinner's
// or a skeleton's endless animation would never settle.
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
await page.waitForFunction(() =>
document
.getAnimations()
.every((a) => !(a instanceof CSSTransition) || a.playState !== "running"),
);
const results = await new AxeBuilder({ page })
.include(
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
@@ -346,3 +358,177 @@ test.describe("mental-health check-in, in the reader's language", () => {
).toHaveCount(0);
});
});
/**
* A WHO-5 check-in, word for word, in German and in Brazilian Portuguese,
* on the wellbeing journey's own account (one per project,
* `critical-fixture.ts`) against the real server.
*
* The journeys above anchor on data attributes so they read the same in every
* language; this one reads the words. The German text is the validated WHO
* translation, which the form has to reproduce exactly; the Brazilian one is
* HealthLog's own and has to say so. Either way the stored assessment records
* the language and the kind of wording it was answered in, and the score the
* server computed is the one on screen.
*
* The strings are written out here rather than imported from
* `src/lib/mental-health/wording/`: a test that read the expected text from
* the module it checks would pass on any edit to that module.
*/
const WHO5_WORDING = {
de: {
validated: true,
progress: "Frage 1 von 5",
stem: "In den letzten zwei Wochen …",
first: "… war ich froh und guter Laune",
last: "… war mein Alltag voller Dinge, die mich interessieren",
// In the order the form prints them, best first.
anchors: [
"Die ganze Zeit",
"Meistens",
"Etwas mehr als die Hälfte der Zeit",
"Etwas weniger als die Hälfte der Zeit",
"Ab und zu",
"Zu keinem Zeitpunkt",
],
},
"pt-BR": {
validated: false,
progress: "Pergunta 1 de 5",
stem: "Nas últimas duas semanas …",
first: "Eu me senti alegre e de bom humor",
last: "Meu dia a dia foi preenchido com coisas que me interessam",
anchors: [
"O tempo todo",
"A maior parte do tempo",
"Mais da metade do tempo",
"Menos da metade do tempo",
"Algumas vezes",
"Em nenhum momento",
],
},
} as const;
/** The account's stored assessments, as the list route answers them. */
async function storedAssessments(page: Page): Promise<
Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>
> {
const res = await page.request.get("/api/mental-health/assessments");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
assessments: Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>;
};
};
return data.assessments.map(
({ instrument, locale, wording, totalScore }) => ({
instrument,
locale,
wording,
totalScore,
}),
);
}
test.describe("WHO-5 check-in, word for word", () => {
test.use({ storageState: criticalStorageState("wellbeing") });
// Both locales write to the one account of the project, and each test
// empties it first; in order, so neither empties the other's mid-run.
test.describe.configure({ mode: "default" });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("wellbeing", testInfo), [
"DELETE FROM mental_health_assessments WHERE user_id = $1",
]);
});
for (const [locale, words] of Object.entries(WHO5_WORDING)) {
test(`${locale}: the form reads the ${words.validated ? "validated" : "HealthLog"} translation and the result is stored in it @critical`, async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, locale);
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await page
.locator('[data-slot="instrument-card"][data-instrument="WHO5"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
'[data-slot="mental-health-check-in"][data-instrument="WHO5"]',
);
const step = (n: number) =>
wizard.locator(`[data-slot="check-in-step"][data-step="${n}"]`);
await expect(step(1)).toBeVisible();
await expect(
wizard.locator('[data-slot="check-in-progress"]'),
).toContainText(words.progress);
await expect(step(1).locator('[data-slot="check-in-stem"]')).toHaveText(
words.stem,
);
await expect(
step(1).locator('[data-slot="check-in-question"]'),
).toHaveText(words.first);
await expect(step(1).locator('[role="radio"]')).toHaveText([
...words.anchors,
]);
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toHaveCount(words.validated ? 0 : 1);
// "Meistens" / "A maior parte do tempo" (value 4) on every item.
for (let n = 1; n <= 5; n++) {
await expect(step(n)).toBeVisible();
if (n === 5) {
await expect(
step(n).locator('[data-slot="check-in-question"]'),
).toHaveText(words.last);
}
await step(n).getByRole("radio", { name: words.anchors[1] }).click();
}
await expect(
wizard.locator('[data-slot="check-in-review"]'),
).toBeVisible();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mental-health/assessments" &&
res.request().method() === "POST",
);
await wizard.locator('[data-slot="check-in-submit"]').click();
expect((await created).status()).toBe(201);
// Five items at 4, times WHO-5's multiplier of 4.
const result = page.locator(
'[data-slot="mental-health-result"][data-instrument="WHO5"]',
);
await expect(result.locator('[data-slot="result-score"]')).toContainText(
"80",
);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(words.validated ? 0 : 1);
expect(await storedAssessments(page)).toEqual([
{
instrument: "WHO5",
locale,
wording: words.validated ? "validated" : "unvalidated_translation",
totalScore: 80,
},
]);
});
}
});
+66 -32
View File
@@ -1,39 +1,53 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* Logging a mood through the form, against the real server: the time stays
* visible while the details are collapsed, the instant typed into it is the
* instant stored, and the entry is in the list afterwards.
*
* Until v1.46 this spec answered the POST and the list read itself, so the
* list was always empty and the POST always succeeded. The account is now the
* mood journey's own, one per project (`critical-fixture.ts`), emptied before
* the test; the empty state the form opens from is the account's real one.
*/
/** The account's mood entries, as the list route answers them. */
async function storedMoods(
page: Page,
): Promise<Array<{ mood: string; moodLoggedAt: string }>> {
const res = await page.request.get("/api/mood-entries");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { entries: Array<{ mood: string; moodLoggedAt: string }> };
};
return data.entries.map(({ mood, moodLoggedAt }) => ({ mood, moodLoggedAt }));
}
test.describe("mood timestamp capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.use({ storageState: criticalStorageState("mood") });
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("mood", testInfo), [
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
]);
});
test("a mood logged at a chosen time is stored at that instant and listed @critical", async ({
page,
}) => {
let submitted: Record<string, unknown> | null = null;
await page.route("**/api/mood-entries*", async (route) => {
if (route.request().method() === "POST") {
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
error: null,
}),
});
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
// The list is mocked empty, so its empty state carries the add action
// (the header drops its own while there is nothing to list).
// An empty list carries the add action in its empty state (the header
// drops its own while there is nothing to list).
await page.getByRole("button", { name: "Log your first mood" }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
@@ -53,12 +67,32 @@ test.describe("mood timestamp capture", () => {
await timeInput.fill("09:45");
await timeInput.blur();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mood-entries" &&
res.request().method() === "POST",
);
await page.getByRole("button", { name: "Save", exact: true }).click();
expect((await created).status()).toBe(201);
await expect.poll(() => submitted).not.toBeNull();
expect(submitted).toMatchObject({
mood: "SUPER_GUT",
moodLoggedAt: "2026-01-15T08:45:00.000Z",
// 09:45 in Berlin in January is 08:45 UTC.
expect(await storedMoods(page)).toEqual([
{ mood: "SUPER_GUT", moodLoggedAt: "2026-01-15T08:45:00.000Z" },
]);
// The list shows it, at either width (the table and the phone cards each
// carry the marker; one of them is on screen).
const rows = page.locator('[data-slot="mood-rows"]').filter({
visible: true,
});
await expect(rows).toBeVisible();
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
// And still after a reload, which reads the list from the server again
// rather than from the cache the mutation updated.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({
page,
context,
}) => {
+82 -19
View File
@@ -1,29 +1,69 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* Lab results from a spreadsheet (#1215), at phone width, against the real
* server: the mapping is suggested from the headers, the check reports a
* refused row by line and writes nothing, the import writes the rest, and a
* second import of the same file only finds duplicates.
* Lab results from a spreadsheet (#1215), against the real server: the
* mapping is suggested from the headers, the check reports a refused row by
* line and writes nothing, the import writes the rest, the imported row is on
* the labs page, and a second import of the same file only finds duplicates.
*
* The account is the labs journey's own, one per project
* (`critical-fixture.ts`), emptied of results first. Before v1.46 this ran on
* the shared account in both projects, so the "only duplicates" verdict of
* one project could be the other project's first import.
*/
const ANALYTE = "E2E Marker CSV";
function csv(): string {
const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
return [
"Analyte;Value;Unit;Date",
`E2E Marker CSV;5,4;mmol/L;${day}`,
`E2E Marker CSV;1.234;mmol/L;${day}`,
// A decimal comma, as a German lab export writes it.
`${ANALYTE};5,4;mmol/L;${day}`,
// Either 1.234 or 1234: refused as ambiguous rather than guessed.
`${ANALYTE};1.234;mmol/L;${day}`,
].join("\n");
}
/** The account's lab results, as the list route answers them. */
async function storedResults(
page: Page,
): Promise<Array<{ analyte: string; value: number | null; unit: string }>> {
const res = await page.request.get("/api/labs");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
results: Array<{ analyte: string; value: number | null; unit: string }>;
};
};
return data.results.map(({ analyte, value, unit }) => ({
analyte,
value,
unit,
}));
}
test.describe("record CSV import", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
test.use({ storageState: criticalStorageState("labs") });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("labs", testInfo), [
"DELETE FROM lab_results WHERE user_id = $1",
]);
});
test("checks, imports and then only finds duplicates", async ({ page }) => {
test("checks, imports, lists the result and then only finds duplicates @critical", async ({
page,
}) => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
const sheet = page.locator('[data-slot="record-import-sheet"]');
@@ -48,19 +88,42 @@ test.describe("record CSV import", () => {
await expect(
page.locator('[data-slot="record-import-error-row"]'),
).toHaveCount(1);
// The check is a dry run.
expect(await storedResults(page)).toEqual([]);
await page.locator('[data-slot="record-import-run"]').click();
await expect(
page.locator('[data-slot="record-import-done"]'),
).toBeVisible();
expect(await storedResults(page)).toEqual([
{ analyte: ANALYTE, value: 5.4, unit: "mmol/L" },
]);
// Second run of the same file.
await page.keyboard.press("Escape");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
await test.step("the result is on the labs page", async () => {
await page.goto("/labs");
const analyte = page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: ANALYTE })
.filter({ visible: true });
await expect(analyte).toHaveCount(1);
// The cell the name heads carries the latest reading under it.
await expect(analyte.locator("xpath=../..")).toContainText("5.4");
});
await test.step("a second run of the same file", async () => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page
.locator('[data-slot="record-import-file"]')
.setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-checked"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
expect(await storedResults(page)).toHaveLength(1);
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* A settings change is the server's, not the tab's.
*
* The time-format choice is changed through the account settings, then read
* back three ways that a client-side cache could not fake: after a reload, in
* a second browser context that shares the session but none of the first
* one's storage, and on `/api/auth/me`, which every client renders from.
*
* The account is the settings journey's own, one per project
* (`critical-fixture.ts`), and is put back to the default first.
*/
async function openTimeFormat(page: Page) {
await page.goto("/settings/account");
const select = page.getByTestId("settings-time-format-select");
await expect(select).toBeVisible({ timeout: 30_000 });
// The control is disabled until the account payload has landed.
await expect(select).toBeEnabled();
return select;
}
async function publishedTimeFormat(page: Page): Promise<string> {
const res = await page.request.get("/api/auth/me");
expect(res.status()).toBe(200);
return ((await res.json()) as { data: { timeFormat: string } }).data
.timeFormat;
}
test.describe("settings persistence", () => {
test.use({ storageState: criticalStorageState("settings") });
test.beforeEach(async ({}, testInfo) => {
await resetCriticalAccount(criticalAccount("settings", testInfo), [
"UPDATE users SET time_format = 'AUTO' WHERE id = $1",
]);
});
test("a time-format change survives a reload and reaches a second browser @critical", async ({
page,
browser,
}, testInfo) => {
const select = await openTimeFormat(page);
await expect(select).toHaveValue("AUTO");
const saved = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/user/profile" &&
res.request().method() === "PATCH",
);
await select.selectOption("H12");
expect((await saved).status()).toBe(200);
expect(await publishedTimeFormat(page)).toBe("H12");
await page.reload();
await expect(page.getByTestId("settings-time-format-select")).toHaveValue(
"H12",
);
// Same session cookie, nothing else: no local storage, no IndexedDB
// query snapshot, no in-memory cache.
const other = await browser.newContext({
storageState: criticalAccount("settings", testInfo).storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
try {
const second = await other.newPage();
await expect(await openTimeFormat(second)).toHaveValue("H12");
} finally {
await other.close();
}
});
});
+253
View File
@@ -0,0 +1,253 @@
/**
* Accounts for the `@critical` journeys: one per journey AND per project.
*
* The `@critical` journeys run against the real server with no `/api` stubs
* (`src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds that line), so
* every verdict they read is a statement about a real record. Two things would
* make those verdicts somebody else's:
*
* - a sibling spec writing into the same account. The shared fixture account
* is read and written by sixty files, so "the dashboard tile shows the
* weight I just entered" is only true of an account nobody else writes;
* - the same journey running in the other project. Every other journey that
* owns its account answers that by running in ONE project (the long list in
* `playwright.config.ts`). These journeys are the release gate and are
* meant to hold at both widths, so instead of dropping a project each
* project gets its own account: `chromium-desktop` and `chromium-mobile`
* run the same file in parallel and never meet.
*
* The accounts are seeded and signed in by `global-setup.ts`, like every
* other fixture account; this module only names them and resets what the
* journeys write. It talks to Postgres through `pg` for the reason
* `global-setup.ts` gives (Playwright's loader cannot load the generated
* Prisma client).
*/
import { resolve } from "node:path";
import type { TestInfo } from "@playwright/test";
import pg from "pg";
/** The journeys that own an account pair. */
export const CRITICAL_JOURNEYS = [
"auth",
"measurement",
"mood",
"labs",
"wellbeing",
"settings",
// The fresh account a full backup is imported into. The backup journey
// runs in the desktop project only (`playwright.config.ts` says why), so
// only the desktop account of this pair is ever written.
"restore",
] as const;
export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number];
/**
* Journeys that sign in through the form in every test and so get no jar.
* The sign-in journey ends the sessions it opens; a jar captured once in
* global setup would be dead for a retry, and a retry has to start from the
* same place as the first attempt.
*/
export const FORM_SIGN_IN_JOURNEYS: ReadonlySet<CriticalJourney> = new Set([
"auth",
]);
/** The projects the journeys run in; each gets its own account per journey. */
export const CRITICAL_PROJECTS = {
"chromium-desktop": "d",
"chromium-mobile": "m",
} as const;
type CriticalProject = keyof typeof CRITICAL_PROJECTS;
export interface CriticalAccount {
username: string;
email: string;
password: string;
storageStatePath: string;
}
function accountFor(
journey: CriticalJourney,
suffix: (typeof CRITICAL_PROJECTS)[CriticalProject],
): CriticalAccount {
const username = `e2e-critical-${journey}-${suffix}`;
return {
username,
email: `${username}@healthlog.test`,
// Fixture credential for a throwaway test database, like every other
// account in `global-setup.ts`. Long and mixed so it would also clear the
// registration strength gate.
password: `Cq7!${journey}-${suffix}-Wz4pLx`,
storageStatePath: resolve(
process.cwd(),
`e2e/setup/storageStateCritical-${journey}-${suffix}.json`,
),
};
}
/** Every critical account, for `global-setup.ts` to seed and sign in. */
export function allCriticalAccounts(): Array<
CriticalAccount & { journey: CriticalJourney }
> {
return CRITICAL_JOURNEYS.flatMap((journey) =>
Object.values(CRITICAL_PROJECTS).map((suffix) => ({
journey,
...accountFor(journey, suffix),
})),
);
}
/**
* The account a journey uses in the project the test is running in. A project
* outside the map has no account on purpose: a critical journey that grows a
* third project has to say which account it reads.
*/
export function criticalAccount(
journey: CriticalJourney,
testInfo: TestInfo,
): CriticalAccount {
const suffix = CRITICAL_PROJECTS[testInfo.project.name as CriticalProject];
if (!suffix) {
throw new Error(
`[critical-fixture] no ${journey} account for project ${testInfo.project.name}`,
);
}
return accountFor(journey, suffix);
}
/**
* A `test.use({ storageState })` value that picks the jar of the running
* project's account.
*/
export function criticalStorageState(journey: CriticalJourney) {
return async (
{}: object,
use: (path: string) => Promise<void>,
testInfo: TestInfo,
): Promise<void> => {
await use(criticalAccount(journey, testInfo).storageStatePath);
};
}
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[critical-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
return await fn(pool);
} finally {
await pool.end();
}
}
/**
* Run statements against one critical account, with `$1` bound to its id.
* Fixture hygiene only: what a previous run or a retry left behind. Nothing a
* journey asserts is written here.
*/
export async function resetCriticalAccount(
account: CriticalAccount,
statements: readonly string[],
): Promise<void> {
await withPool(async (pool) => {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[account.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
`[critical-fixture] ${account.username} not seeded — global-setup must run first`,
);
}
for (const sql of statements) await pool.query(sql, [id]);
});
}
/**
* Clear the login buckets. The ceiling is five attempts per IP per quarter
* hour, the suite shares one IP, and global setup alone signs in more than
* twenty times; a journey that signs in through the form would otherwise meet
* the fixture's 429 rather than the product. Only the auth surfaces' buckets
* are touched, exactly as global setup does between its own logins.
*/
export async function clearLoginBuckets(): Promise<void> {
await withPool(async (pool) => {
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
});
}
/**
* Delete a critical account and create it again, empty, under the same id,
* username and password. The backup journey restores a person's file into
* the account the way the runbook brings one back on a new host, where the
* file names its owner by id; a browser cannot delete and recreate a row
* under a chosen id, so this is the fixture's act, not the journey's.
*/
export async function recreateCriticalAccount(
account: CriticalAccount,
): Promise<void> {
await withPool(async (pool) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const res = await client.query<{
id: string;
password_hash: string;
module_preferences_json: unknown;
}>(
`SELECT id, password_hash, module_preferences_json
FROM users WHERE username = $1`,
[account.username],
);
const row = res.rows[0];
if (!row) {
throw new Error(`[critical-fixture] ${account.username} not seeded`);
}
await client.query("DELETE FROM users WHERE id = $1", [row.id]);
await client.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, $5::jsonb)`,
[
row.id,
account.username,
account.email,
row.password_hash,
JSON.stringify(row.module_preferences_json),
],
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
});
}
/** How many measurements and mood entries a critical account holds. */
export async function accountRowCounts(
account: CriticalAccount,
): Promise<{ measurements: number; moodEntries: number }> {
return withPool(async (pool) => {
const res = await pool.query<{ measurements: string; mood: string }>(
`SELECT
(SELECT count(*) FROM measurements m
JOIN users u ON u.id = m.user_id WHERE u.username = $1)
AS measurements,
(SELECT count(*) FROM mood_entries e
JOIN users u ON u.id = e.user_id WHERE u.username = $1) AS mood`,
[account.username],
);
return {
measurements: Number(res.rows[0]?.measurements ?? 0),
moodEntries: Number(res.rows[0]?.mood ?? 0),
};
});
}
+51
View File
@@ -33,6 +33,7 @@ import {
} from "@playwright/test";
import pg from "pg";
import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
@@ -1539,6 +1540,49 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username],
);
// ── the @critical journeys' accounts ─────────────────────────────────
//
// One per journey and per project; `critical-fixture.ts` says why. Every
// module a journey reaches is switched on (wellbeing and labs are opt-in),
// and the passkeys the sign-in journey registers go with the row's reset,
// so a re-run registers into an empty list rather than beside the last
// run's credential.
for (const account of allCriticalAccounts()) {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
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,
module_preferences_json = EXCLUDED.module_preferences_json,
totp_confirmed_at = NULL`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
now,
JSON.stringify({
mood: true,
labs: true,
mentalHealth: true,
medications: true,
}),
],
);
await pool.query(
`DELETE FROM passkeys
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[account.username],
);
}
// Console (instead of structured logging) is intentional here —
// global-setup runs outside the app's logging context, and the
// line is useful when debugging a CI failure where the seed didn't
@@ -1708,6 +1752,13 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
[E2E_SETUP_CHILD.username, now],
);
// The @critical journeys' jars. The sign-in journey signs in through the
// form in every test, so its account gets none.
for (const account of allCriticalAccounts()) {
if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue;
await capture(account, account.storageStatePath);
}
} finally {
await pool.end();
}
+2 -1
View File
@@ -57,7 +57,8 @@ export async function resetSetupFlow(username: string): Promise<void> {
WHERE key IN (
'onboarding-answers:' || $1,
'onboarding-restart:' || $1,
'managed-profile:create:' || $1
'managed-profile:create:' || $1,
'record-write:' || $1
)`,
[id],
);
+18 -3
View File
@@ -22,13 +22,28 @@ import { mockPopulatedInsights } from "./utils/mock-populated-insights";
test.use({ storageState: STORAGE_STATE_PATH });
async function resetDocumentsLayout(page: Page): Promise<void> {
async function putDocumentsLayout(
page: Page,
arrangement: "stacked" | "flow",
): Promise<void> {
const res = await page.request.put("/api/documents/inbound/layout", {
data: { version: 1, view: "cards", arrangement: "stacked" },
data: { version: 1, view: "cards", arrangement },
});
expect(res.status()).toBe(200);
}
/** The journey starts from the stacked arrangement so the flip is visible. */
const resetDocumentsLayout = (page: Page) =>
putDocumentsLayout(page, "stacked");
/**
* The account is shared with `documents.spec.ts`, which reads the default
* (flowing) arrangement, so the journey hands the account back on the
* default rather than on the state it started from.
*/
const restoreDefaultDocumentsLayout = (page: Page) =>
putDocumentsLayout(page, "flow");
test.describe("documents", () => {
test.beforeAll(async () => {
await ensureVaultFixture();
@@ -87,7 +102,7 @@ test.describe("documents", () => {
).toHaveAttribute("data-arrangement", "flow");
await second.close();
} finally {
await resetDocumentsLayout(page);
await restoreDefaultDocumentsLayout(page);
}
});
+3
View File
@@ -214,6 +214,9 @@ const eslintConfig = defineConfig([
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
// HealthChart's plot layers, imported by health-chart.tsx alone, so
// they sit inside the same chart-runtime graph.
"src/components/charts/health-chart/chart-layers.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
+4
View File
@@ -100,6 +100,9 @@
"e2e/**/*.test.ts",
"tests/**/*.test.ts",
"tests/**/setup.ts",
// Reached only through the browser project's `next/navigation` alias in
// vitest.config.mts, which no import statement names.
"tests/browser/next-navigation.ts",
"vitest.config.ts",
"vitest.config.integration.ts",
"playwright.config.ts",
@@ -111,6 +114,7 @@
"src/**/*.{ts,tsx}!",
"scripts/**/*.{ts,mjs}!",
"tests/**/*.ts",
"tests/**/*.tsx",
"e2e/**/*.ts",
],
"ignore": [
+4 -1
View File
@@ -46,6 +46,7 @@
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mts",
"e2e": "playwright test",
"e2e:critical": "playwright test --grep @critical",
"e2e:ui": "playwright test --ui",
"openapi:generate": "npx tsx scripts/generate-openapi.ts",
"openapi:check": "npx tsx scripts/check-openapi.ts",
@@ -120,6 +121,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vitest/browser-playwright": "^5.0.5",
"@vitest/coverage-v8": "^5.0.5",
"ajv": "^8.20.0",
"eslint": "^9",
@@ -136,6 +138,7 @@
"testcontainers": "^12.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^5.0.5"
"vitest": "^5.0.5",
"vitest-browser-react": "^2.3.0"
}
}
+8
View File
@@ -27,6 +27,14 @@ import {
*
* To run locally: `pnpm dlx playwright install --with-deps chromium`
* once, then `pnpm e2e`.
*
* The journeys tagged `@critical` (`pnpm e2e:critical`) are the exception to
* the route interception above: they intercept nothing under `/api`, which
* `src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds. Most of them
* run in both Chromium projects on an account per project
* (`e2e/setup/critical-fixture.ts`), so they need no entry in the
* `chromium-mobile` ignore list below; the ones that are on that list are
* there for the reason written beside them.
*/
/**
* Drawn once here, before any server or worker exists, and published through
+148 -7
View File
@@ -241,9 +241,12 @@ importers:
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
'@vitest/browser-playwright':
specifier: ^5.0.5
version: 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/coverage-v8':
specifier: ^5.0.5
version: 5.0.5(vitest@5.0.5)
version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -291,7 +294,10 @@ importers:
version: 6.0.3
vitest:
specifier: ^5.0.5
version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
vitest-browser-react:
specifier: ^2.3.0
version: 2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5)
packages:
@@ -708,6 +714,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@blazediff/core@1.10.0':
resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==}
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
@@ -3403,6 +3412,17 @@ packages:
'@visx/vendor@4.0.0-alpha.0':
resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==}
'@vitest/browser-playwright@5.0.5':
resolution: {integrity: sha512-lcdThFUUcMV9mGGV1SUiZXPxfjgW4CssA7o3STxzh8WK0tT2r526kc12tZIBRZfoLMwkhxjLmMXkWXONzdsDQw==}
peerDependencies:
playwright: '*'
vitest: 5.0.5
'@vitest/browser@5.0.5':
resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==}
peerDependencies:
vitest: 5.0.5
'@vitest/coverage-v8@5.0.5':
resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==}
peerDependencies:
@@ -3431,9 +3451,20 @@ packages:
vite:
optional: true
'@vitest/pretty-format@5.0.5':
resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==}
'@vitest/spy@5.0.5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
'@vitest/ui@5.0.5':
resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==}
peerDependencies:
vitest: 5.0.5
'@vitest/utils@5.0.5':
resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -5973,6 +6004,10 @@ packages:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6521,6 +6556,10 @@ packages:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -7057,6 +7096,20 @@ packages:
yaml:
optional: true
vitest-browser-react@2.3.0:
resolution: {integrity: sha512-S5vdGHHA93j2Z6dbmIJI1wtxT3AXPTRlmC1Meo+ekG011yNnEutMAnHrwyj8qFlGhgBYA8ENGK6+o1Q+vnFz6A==}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
vitest: ^4.0.0 || ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
vitest@5.0.5:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
@@ -7189,6 +7242,18 @@ packages:
utf-8-validate:
optional: true
ws@8.22.0:
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@1.0.1:
resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==}
engines: {node: '>=20'}
@@ -7877,6 +7942,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blazediff/core@1.10.0': {}
'@cfworker/json-schema@4.1.1':
optional: true
@@ -10042,7 +10109,7 @@ snapshots:
'@stryker-mutator/util': 10.0.0
semver: 7.8.5
tslib: 2.8.1
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
'@swc/helpers@0.5.23':
dependencies:
@@ -10549,7 +10616,38 @@ snapshots:
d3-time-format: 4.1.0
internmap: 2.0.3
'@vitest/coverage-v8@5.0.5(vitest@5.0.5)':
'@vitest/browser-playwright@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)':
dependencies:
'@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
playwright: 1.64.1
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)':
dependencies:
'@blazediff/core': 1.10.0
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
'@vitest/ui': 5.0.5(vitest@5.0.5)
'@vitest/utils': 5.0.5
magic-string: 1.4.3
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
ws: 8.22.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/coverage-v8@5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.2
@@ -10559,7 +10657,9 @@ snapshots:
obug: 3.0.0
std-env: 4.3.0
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
optionalDependencies:
'@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/istanbul-lib-coverage@1.0.2': {}
@@ -10577,8 +10677,28 @@ snapshots:
msw: 2.14.6(@types/node@26.6.5)(typescript@6.0.3)
vite: 8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)
'@vitest/pretty-format@5.0.5':
dependencies:
tinyrainbow: 3.2.0
'@vitest/spy@5.0.5': {}
'@vitest/ui@5.0.5(vitest@5.0.5)':
dependencies:
'@vitest/utils': 5.0.5
fflate: 0.8.3
flatted: 3.4.4
pathe: 2.0.3
sirv: 3.0.2
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
'@vitest/utils@5.0.5':
dependencies:
'@vitest/pretty-format': 5.0.5
convert-source-map: 2.0.0
tinyrainbow: 3.2.0
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -13201,6 +13321,8 @@ snapshots:
pngjs@5.0.0: {}
pngjs@7.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.6:
@@ -13882,6 +14004,12 @@ snapshots:
mrmime: 2.0.1
totalist: 3.0.1
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
sisteransi@1.0.5: {}
smart-buffer@4.2.0: {}
@@ -14468,7 +14596,16 @@ snapshots:
jiti: 2.7.0
yaml: 2.9.1
vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)):
vitest-browser-react@2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5):
dependencies:
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
optionalDependencies:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
@@ -14487,7 +14624,9 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 26.6.5
'@vitest/coverage-v8': 5.0.5(vitest@5.0.5)
'@vitest/browser-playwright': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/coverage-v8': 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)
'@vitest/ui': 5.0.5(vitest@5.0.5)
transitivePeerDependencies:
- msw
@@ -14610,6 +14749,8 @@ snapshots:
ws@7.5.13: {}
ws@8.22.0: {}
wsl-utils@1.0.1:
dependencies:
is-wsl: 3.1.1
@@ -139,7 +139,7 @@ const AI_REQUEST_SITES: Record<string, RequestSite> = {
},
"components/documents/use-document-assist.ts": {
capability: "documentAi",
gate: "components/documents/document-detail-sheet.tsx",
gate: "components/documents/detail/use-document-detail-ai.ts",
},
};
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 366,
"total": 126106,
"count": 355,
"total": 114858,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -51,14 +51,10 @@
"app/labs/page.tsx :: LabsPage": 228,
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
"app/notifications/page.tsx :: NotificationsPage": 327,
"app/page-client.tsx :: DashboardPageClient": 2131,
"app/page-client.tsx :: DashboardPageClient > (anonymous)": 1279,
"components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470,
"components/admin/admin-shell.tsx :: AdminShell": 254,
"components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212,
"components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341,
"components/admin/backups-section.tsx :: BackupsSection": 618,
"components/admin/backups-section.tsx :: RestoreRowDialog": 226,
"components/admin/central-codex-section.tsx :: CentralCodexSection": 241,
"components/admin/encryption-section.tsx :: EncryptionSection": 303,
"components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220,
@@ -74,7 +70,7 @@
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
"components/charts/health-chart.tsx :: HealthChart": 1929,
"components/charts/health-chart.tsx :: HealthChart": 488,
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
"components/charts/mood-chart.tsx :: MoodChart": 896,
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
@@ -98,13 +94,13 @@
"components/documents/document-ai-section.tsx :: DocumentAiSection": 262,
"components/documents/document-bulk-bar.tsx :: DocumentBulkBar": 204,
"components/documents/document-card.tsx :: DocumentCard": 354,
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 935,
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325,
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 252,
"components/documents/document-facts-review.tsx :: FactRow": 262,
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
"components/documents/documents-view.tsx :: DocumentsView": 969,
"components/documents/documents-view.tsx :: DocumentsView": 303,
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 565,
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
@@ -154,17 +150,11 @@
"components/layout/top-bar.tsx :: TopBar": 212,
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeCard": 635,
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 618,
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
"components/measurements/measurement-form.tsx :: MeasurementForm": 500,
"components/measurements/measurement-list.tsx :: MeasurementList": 1586,
"components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg)": 223,
"components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg) #2": 229,
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771,
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
"components/medications/dose-history-ledger.tsx :: DoseHistoryLedger": 404,
"components/medications/dose-history-ledger.tsx :: LedgerRowItem": 314,
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
@@ -213,7 +203,6 @@
"components/settings/api-section.tsx :: ApiTokensCard": 366,
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
"components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350,
"components/settings/dashboard-layout-section.tsx :: DashboardLayoutSection": 620,
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
"components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298,
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
+2 -13
View File
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 348,
"total": 348,
"count": 337,
"total": 337,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -143,18 +143,13 @@
"__tests__/utc-day-key-allowlist-guard.test.ts": 1,
"__tests__/v1341-locale-parity.test.ts": 1,
"__tests__/visit-link-guard.test.ts": 1,
"__tests__/weight-tile-direction-guard.test.ts": 1,
"__tests__/workout-canonical-identity-guard.test.ts": 1,
"__tests__/write-rate-limit-guard.test.ts": 1,
"__tests__/written-outcome-response-consumer-guard.test.ts": 1,
"__tests__/zod-refusal-issue-list-guard.test.ts": 1,
"app/__tests__/dashboard-analytics-split.test.ts": 1,
"app/__tests__/dashboard-chart-reveal.test.tsx": 1,
"app/__tests__/dashboard-demo-checklist.test.ts": 1,
"app/__tests__/dashboard-hero-wiring.test.ts": 1,
"app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx": 1,
"app/__tests__/dashboard-suspense-boundaries.test.ts": 1,
"app/__tests__/dashboard-tile-strip-skeleton.test.ts": 1,
"app/__tests__/i18n-boot-script-strategy.test.ts": 1,
"app/__tests__/insights-b3-wiring.test.ts": 1,
"app/__tests__/insights-polish.test.ts": 1,
@@ -192,11 +187,9 @@
"components/charts/__tests__/touch-action-guard.test.ts": 1,
"components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx": 1,
"components/dashboard/__tests__/medication-intake-quick-add.test.tsx": 1,
"components/dashboard/__tests__/tile-destinations.test.ts": 1,
"components/day/__tests__/day-door-coverage-guard.test.ts": 1,
"components/day/__tests__/day-not-here-guard.test.ts": 1,
"components/documents/__tests__/document-coach-launch-wiring.test.ts": 1,
"components/documents/__tests__/document-detail-download.test.ts": 1,
"components/documents/__tests__/documents-delete-confirm-guard.test.ts": 1,
"components/forms/__tests__/use-seeded-form-dismissal.test.ts": 1,
"components/i18n/__tests__/maintainership-banner.test.tsx": 1,
@@ -219,8 +212,6 @@
"components/layout/__tests__/offline-banner.test.tsx": 1,
"components/layout/__tests__/sidebar-nav.test.tsx": 1,
"components/measurements/__tests__/duration-read-as.test.tsx": 1,
"components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1,
"components/measurements/__tests__/measurement-list-glucose-unit.test.tsx": 1,
"components/medications/__tests__/add-inventory-dialog.test.tsx": 1,
"components/medications/__tests__/detail-page-cascade.test.tsx": 1,
"components/medications/__tests__/inventory-section-dates.test.tsx": 1,
@@ -270,8 +261,6 @@
"components/timeline/__tests__/no-template-key-guard.test.ts": 1,
"components/ui/__tests__/card-density.test.tsx": 1,
"components/ui/__tests__/card-header-rows.test.ts": 1,
"components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/__tests__/segmented.test.tsx": 1,
"hooks/__tests__/use-insight-status.test.ts": 1,
"lib/__tests__/geo-offline-detection.test.ts": 1,
"lib/__tests__/query-keys.test.ts": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2909,
"total": 9577,
"count": 2898,
"total": 9297,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -671,7 +671,6 @@
"app/onboarding/[step]/page.tsx": 1,
"app/onboarding/handover/page.tsx": 1,
"app/onboarding/page.tsx": 2,
"app/page-client.tsx": 81,
"app/privacy/__tests__/page.test.tsx": 1,
"app/privacy/page.tsx": 1,
"app/settings/[section]/page.tsx": 1,
@@ -708,7 +707,6 @@
"components/admin/api-token-overview-section.tsx": 5,
"components/admin/app-log-preview-section.tsx": 1,
"components/admin/assistant-section.tsx": 1,
"components/admin/backups-section.tsx": 4,
"components/admin/coach-feedback-section.tsx": 1,
"components/admin/danger-zone-section.tsx": 1,
"components/admin/general-settings-section.tsx": 1,
@@ -761,7 +759,6 @@
"components/charts/chart-tooltip.tsx": 1,
"components/charts/compliance-heatmap.tsx": 6,
"components/charts/health-chart-dynamic.tsx": 3,
"components/charts/health-chart.tsx": 82,
"components/charts/medication-compliance-chart.tsx": 21,
"components/charts/mini-window.ts": 1,
"components/charts/mood-chart.tsx": 33,
@@ -828,10 +825,8 @@
"components/documents/document-ai-transport.ts": 2,
"components/documents/document-bulk-bar.tsx": 1,
"components/documents/document-card.tsx": 2,
"components/documents/document-detail-sheet.tsx": 6,
"components/documents/document-facts-review.tsx": 2,
"components/documents/document-summary-block.tsx": 2,
"components/documents/documents-view.tsx": 3,
"components/documents/use-content-index.ts": 1,
"components/documents/use-document-assist.ts": 1,
"components/documents/use-document-facts.ts": 1,
@@ -1116,9 +1111,7 @@
"components/layout/top-bar.tsx": 4,
"components/measurement-reminders/__tests__/vorsorge-section.test.tsx": 7,
"components/measurement-reminders/vorsorge-dashboard-card.tsx": 2,
"components/measurement-reminders/vorsorge-section.tsx": 26,
"components/measurement-reminders/vorsorge-settings.tsx": 2,
"components/measurements/__tests__/measurement-form-unit-label.test.tsx": 1,
"components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx": 4,
"components/measurements/__tests__/measurement-list-bp-badge.test.tsx": 3,
"components/measurements/__tests__/measurement-list-empty.test.tsx": 1,
@@ -1126,7 +1119,6 @@
"components/measurements/__tests__/measurement-list-locked-type.test.tsx": 2,
"components/measurements/__tests__/measurement-list-step-grouping.test.tsx": 1,
"components/measurements/measurement-form.tsx": 11,
"components/measurements/measurement-list.tsx": 39,
"components/medications/__tests__/add-inventory-dialog.test.tsx": 1,
"components/medications/__tests__/card-parts.test.tsx": 13,
"components/medications/__tests__/detail-page-cascade.test.tsx": 1,
@@ -1168,7 +1160,6 @@
"components/medications/detail/medication-detail-tabs.tsx": 28,
"components/medications/dose-history-add-dialog.tsx": 2,
"components/medications/dose-history-ledger-compute.ts": 5,
"components/medications/dose-history-ledger.tsx": 7,
"components/medications/dose-strength-curve.tsx": 2,
"components/medications/drug-level-chart.tsx": 2,
"components/medications/glp1-medication-card.tsx": 36,
@@ -1374,7 +1365,6 @@
"components/settings/coach-prefs-section.tsx": 2,
"components/settings/coach-reminders-section.tsx": 2,
"components/settings/coach-section.tsx": 3,
"components/settings/dashboard-layout-section.tsx": 30,
"components/settings/dashboard-section.tsx": 1,
"components/settings/environment-section.tsx": 3,
"components/settings/export-section.tsx": 4,
@@ -1445,7 +1435,6 @@
"components/timeline/__tests__/timeline-view-keys.test.tsx": 1,
"components/timeline/__tests__/value-options.test.ts": 1,
"components/timeline/life-event-form.lazy.tsx": 1,
"components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/back-link.tsx": 1,
"components/ui/button.tsx": 1,
"components/ui/card.tsx": 1,
@@ -84,12 +84,12 @@ const INVENTORY: Readonly<Record<string, Poll>> = {
shape: "until-done",
cadence: "2 s until the job reports a terminal status",
},
"components/admin/backups-section.tsx": {
"components/admin/backups/use-backup-restores.ts": {
sites: 1,
shape: "until-done",
cadence: "2 s until no restore is active",
},
"components/documents/documents-view.tsx": {
"components/documents/vault/use-vault-library.ts": {
sites: 1,
shape: "until-done",
cadence:
@@ -242,7 +242,7 @@ describe("daily-reads refetch guard", () => {
expect(read(rel)).toMatch(/invalidateMedicationReads\(/);
}
const pairInPlace = [
"components/medications/dose-history-ledger.tsx",
"components/medications/dose-history/use-dose-history-ledger.ts",
"components/medications/dose-history-add-dialog.tsx",
];
for (const rel of pairInPlace) {
@@ -46,6 +46,7 @@ const WRITTEN_REASON = /tile-eligibility-exempt:\s*\S/;
const SURFACES = [
"src/app/page-client.tsx",
...listSourceFiles("src/components/dashboard"),
...listSourceFiles("src/components/dashboard/tiles"),
...listSourceFiles("src/lib/dashboard"),
];
@@ -252,15 +252,15 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
why: "The `yyyy-MM-dd` key the ledger groups doses by. The heading a reader sees is rendered separately, through the preference-aware `formatDateWithWeekdaySmart`.",
},
{
file: "src/components/medications/dose-history-ledger.tsx",
file: "src/components/medications/dose-history/use-dose-history-ledger.ts",
why: "The same key shape for `today`, compared against those buckets. A comparison, not a label.",
},
{
file: "src/components/charts/health-chart.tsx",
file: "src/components/charts/health-chart/series-math.ts",
why: "`makeDayKeyFormatter` — the per-row bucketing key. Its fields are read back through `formatToParts` and reassembled as `YYYY-MM-DD`.",
},
{
file: "src/components/measurement-reminders/vorsorge-section.tsx",
file: "src/components/measurement-reminders/checkups/postpone-sheet.tsx",
why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.",
},
{
@@ -316,7 +316,8 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["ConfirmButton"],
},
{
file: "components/settings/dashboard-layout-section.tsx",
file: "components/settings/dashboard-layout/use-dashboard-layout-draft.ts",
triggers: ["components/settings/dashboard-layout-section.tsx"],
destroys:
"the dashboard arrangement: tile order, hidden tiles, ring choice",
recovery: "permanent",
@@ -350,8 +351,13 @@ const REGISTRY: DestructiveEntry[] = [
// ── Measurements, mood, labs, illness — the undo-and-restore surfaces ────
{
file: "components/measurements/measurement-list.tsx",
file: "components/measurements/list/use-measurement-list-data.ts",
destroys: "one measurement, or every selected measurement",
triggers: [
"components/measurements/measurement-list.tsx",
"components/measurements/list/row-actions.tsx",
"components/measurements/list/measurement-edit-sheet.tsx",
],
recovery: "restorable",
confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"],
},
@@ -442,7 +448,10 @@ const REGISTRY: DestructiveEntry[] = [
{
file: "hooks/use-measurement-reminders.ts",
destroys: "a checkup or measurement reminder with its schedule",
triggers: ["components/measurement-reminders/vorsorge-section.tsx"],
triggers: [
"components/measurement-reminders/vorsorge-section.tsx",
"components/measurement-reminders/checkups/checkup-card.tsx",
],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
@@ -519,8 +528,9 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["AlertDialog"],
},
{
file: "components/medications/dose-history-ledger.tsx",
file: "components/medications/dose-history/use-dose-history-ledger.ts",
destroys: "one intake event",
triggers: ["components/medications/dose-history/ledger-confirms.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
@@ -588,8 +598,9 @@ const REGISTRY: DestructiveEntry[] = [
// ── Documents ───────────────────────────────────────────────────────────
{
file: "components/documents/document-detail-sheet.tsx",
file: "components/documents/detail/use-document-detail.ts",
destroys: "one document in the vault",
triggers: ["components/documents/detail/detail-sections.tsx"],
recovery: "restorable",
confirm: [],
unconfirmed: [
@@ -601,9 +612,10 @@ const REGISTRY: DestructiveEntry[] = [
],
},
{
file: "components/documents/documents-view.tsx",
file: "components/documents/vault/use-vault-bulk.ts",
destroys: "every selected document, or one document from a focused card",
triggers: [
"components/documents/documents-view.tsx",
"components/documents/document-bulk-bar.tsx",
"components/documents/document-card.tsx",
],
@@ -59,7 +59,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
* screen unrounded.
*/
const RAW_SCALE_SURFACES = [
"app/page-client.tsx",
// The dashboard's chart cards; the values the dashboard renders itself
// convert in the tile context (see `CONVERTS_IN`).
"components/dashboard/tiles/chart-tiles.tsx",
// The insights trends row: a single transformed series hands its scale
// and offset to the mini chart, which formats every value itself.
"components/insights/trends-row.tsx",
@@ -68,6 +70,16 @@ const RAW_SCALE_SURFACES = [
"components/insights/device-score-tile.tsx",
];
/**
* A surface whose rendered values convert in a sibling module: the dashboard's
* chart cards read the preference through the tile context every dashboard
* tile is built from.
*/
const CONVERTS_IN: Readonly<Record<string, string>> = {
"components/dashboard/tiles/chart-tiles.tsx":
"components/dashboard/tiles/tile-context.ts",
};
/** Matches a `factor` / `offset` read off a display transform. */
const RAW_SCALE_READ =
/\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/;
@@ -192,10 +204,11 @@ describe("display-transform rounding guard — the bypass freeze", () => {
// The justification for holding the raw multiplier at all: the chart
// takes it as a prop. A file that stops passing it has no reason to be
// reading `.factor` any more and belongs back behind the helpers.
// The prop as it is actually passed — a bare substring would still
// The prop as it is actually passed (as a JSX attribute or as a key of
// the props a chart builder spreads) — a bare substring would still
// match a renamed identifier that no longer reaches any chart.
const offenders = RAW_SCALE_SURFACES.filter(
(rel) => !/\bvalueScale=/.test(read(rel)),
(rel) => !/\bvalueScale[=:]/.test(read(rel)),
);
expect(offenders).toEqual([]);
});
@@ -207,7 +220,10 @@ describe("display-transform rounding guard — the bypass freeze", () => {
// `useUnitDisplayOnceMounted()`, which is the same sugar with the
// preference withheld from the hydration render.
const offenders = RAW_SCALE_SURFACES.filter(
(rel) => !/\buseUnitDisplay(OnceMounted)?\(\)/.test(read(rel)),
(rel) =>
!/\buseUnitDisplay(OnceMounted)?\(\)/.test(
read(CONVERTS_IN[rel] ?? rel),
),
);
expect(offenders).toEqual([]);
});
@@ -0,0 +1,280 @@
/**
* Structural guard: an `@critical` e2e journey talks to the real server.
*
* The `@critical` tag marks the journeys a release is gated on — sign-in,
* a reading reaching the dashboard, a dose moving compliance, a backup coming
* back. They are only worth that role if the server under them is the one
* that ships. A spec that stubs `/api/*` with `page.route(...)` proves that
* the UI renders a payload somebody typed into the test, which is how a broken
* write path stays green: the mock answers 200 whatever the route does.
*
* So a spec FILE that carries `@critical` anywhere may not intercept the app's
* API, and the rule is per file rather than per test because a route armed in
* a `beforeEach` or a `describe`-level hook covers every test beside it.
* What it catches:
*
* - `.route(` / `.routeFromHAR(` in the spec whose first argument mentions
* `api`, or is not a literal at all (a pattern held in a variable cannot be
* shown to miss `/api`, so it is refused rather than trusted);
* - a helper imported from a relative module whose exported function arms
* such a route (`stubDashboardAnalytics`, `mockMedicationsApi`, …). The
* helper is read, its exported functions are cut apart at each top-level
* `export`, and any that intercepts `/api` may not be imported by name.
*
* Stubbing a third-party host that is unreachable from CI and is not the
* journey's subject stays possible: a literal pattern without `api` passes.
* The spec is expected to say why next to the call.
*
* It is a tripwire with a scanner's limits, not a parser: a route armed
* through a helper that is not imported by name (a default export, a
* re-export chain) is not followed. The synthetic cases at the bottom prove
* each matcher fires; the floor proves the sweep found the tagged tests at
* all.
*/
import { existsSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import {
readSourceText,
requireFloor,
walkSourceFiles,
} from "./helpers/source-files";
const E2E = join(process.cwd(), "e2e");
/**
* The `@critical` tests found in `e2e/*.spec.ts`. Pinned at the real count:
* the set is the release gate, so a journey that loses its tag is a change
* somebody should see here. It counts title literals in the source, so a
* title written once inside a loop counts once however many tests it names.
*/
const CRITICAL_TEST_FLOOR = 16;
/** A test whose title literal carries the tag, however the call is spelled. */
const CRITICAL_TEST =
/\btest(?:\.(?:only|skip|fixme|fail|slow))?\(\s*(["'`])((?:(?!\1)[\s\S])*?@critical(?:(?!\1)[\s\S])*?)\1/g;
/** A route interception call; the first argument is examined separately. */
const ROUTE_CALL = /\.route(?:FromHAR)?\(\s*/g;
/**
* The source without its comment LINES: a line whose first non-blank text is
* `//`, and a block that opens at the start of a line. Deliberately not the
* shared `stripComments`, which opens a block at any `/*` — and route globs
* (`"**\/api/day/**"`, `"https://host/**"`) are full of them, so it would
* swallow the code after the first glob, including any `.route(` there. Prose
* about this rule lives in comment lines, which is what has to go.
*/
function stripCommentLines(source: string): string {
return source
.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, "")
.replace(/^[ \t]*\/\/.*$/gm, "");
}
interface StubFinding {
/** Where the interception lives (the spec, or the helper it imports). */
where: string;
/** What was found. */
detail: string;
}
/**
* The first argument of a call whose open paren ends at `start`: a string,
* template or regex literal is returned whole; anything else is returned up to
* the first comma or closing paren and flagged as not a literal.
*/
function firstArgument(
source: string,
start: number,
): { text: string; literal: boolean } {
const opener = source[start];
if (opener === '"' || opener === "'" || opener === "`") {
const end = source.indexOf(opener, start + 1);
return { text: source.slice(start, end + 1), literal: true };
}
if (opener === "/") {
// A regex literal: up to the next unescaped slash.
let i = start + 1;
while (i < source.length && !(source[i] === "/" && source[i - 1] !== "\\"))
i += 1;
return { text: source.slice(start, i + 1), literal: true };
}
const rest = source.slice(start);
const stop = rest.search(/[,)]/);
return { text: rest.slice(0, stop === -1 ? 80 : stop), literal: false };
}
/** Every `/api` (or unprovable) interception in one source text. */
function apiStubsIn(source: string): string[] {
const code = stripCommentLines(source);
const hits: string[] = [];
for (const m of code.matchAll(ROUTE_CALL)) {
const arg = firstArgument(code, (m.index ?? 0) + m[0].length);
if (!arg.literal) {
hits.push(`route pattern is not a literal: ${arg.text.trim()}`);
} else if (/api/i.test(arg.text)) {
hits.push(`route intercepts the app's API: ${arg.text}`);
}
}
return hits;
}
/** The names a spec imports from each relative module. */
function relativeImports(
source: string,
): Array<{ names: string[]; from: string }> {
const code = stripCommentLines(source);
const out: Array<{ names: string[]; from: string }> = [];
for (const m of code.matchAll(
/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'](\.{1,2}\/[^"']+)["']/g,
)) {
const names = m[1]
.split(",")
.map((n) =>
n
.trim()
.replace(/^type\s+/, "")
.split(/\s+as\s+/)[0]
.trim(),
)
.filter(Boolean);
out.push({ names, from: m[2] });
}
return out;
}
/**
* Exported functions of a helper that intercept `/api`, by name. The helper is
* cut at each top-level `export`, so each slice is one export's body.
*/
function stubbingExports(helperSource: string): string[] {
const code = stripCommentLines(helperSource);
const starts = [...code.matchAll(/^export\s+/gm)].map((m) => m.index ?? 0);
const names: string[] = [];
starts.forEach((start, i) => {
const slice = code.slice(start, starts[i + 1] ?? code.length);
const name = slice.match(
/^export\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+)([A-Za-z0-9_$]+)/,
)?.[1];
if (name && apiStubsIn(slice).length > 0) names.push(name);
});
return names;
}
function resolveHelper(specRel: string, from: string): string | null {
const base = join(E2E, dirname(specRel), from);
for (const candidate of [`${base}.ts`, `${base}/index.ts`, base]) {
if (candidate.endsWith(".ts") && existsSync(candidate)) return candidate;
}
return null;
}
/** Everything that stubs `/api` in one spec file, its imported helpers included. */
function findApiStubs(
specRel: string,
source: string,
readHelper: (absolute: string) => string = readSourceText,
): StubFinding[] {
const findings: StubFinding[] = apiStubsIn(source).map((detail) => ({
where: specRel,
detail,
}));
for (const { names, from } of relativeImports(source)) {
const helper = resolveHelper(specRel, from);
if (!helper) continue;
const stubbing = new Set(stubbingExports(readHelper(helper)));
for (const name of names) {
if (stubbing.has(name)) {
findings.push({
where: relative(E2E, helper),
detail: `\`${name}\` (imported by ${specRel}) intercepts the app's API`,
});
}
}
}
return findings;
}
function specFiles(): string[] {
return walkSourceFiles(E2E, { floor: 100, extensions: [".ts"] }).filter(
(rel) => !rel.includes("/") && rel.endsWith(".spec.ts"),
);
}
function criticalTests(): Array<{ file: string; title: string }> {
const tests: Array<{ file: string; title: string }> = [];
for (const file of specFiles()) {
const code = stripCommentLines(readSourceText(join(E2E, file)));
for (const m of code.matchAll(CRITICAL_TEST)) {
tests.push({ file, title: m[2] });
}
}
return tests;
}
describe("@critical e2e journeys run against the real API", () => {
it("finds the tagged journeys", () => {
requireFloor("@critical e2e tests", criticalTests(), CRITICAL_TEST_FLOOR);
});
it("no file carrying @critical intercepts /api, directly or through a helper", () => {
const files = new Set(criticalTests().map((t) => t.file));
// A file that mentions the tag outside a test title (a describe title, a
// `grep` option) is held to the same rule.
for (const file of specFiles()) {
if (
stripCommentLines(readSourceText(join(E2E, file))).includes("@critical")
)
files.add(file);
}
const offenders = [...files].flatMap((file) =>
findApiStubs(file, readSourceText(join(E2E, file))).map(
(f) => `${f.where}: ${f.detail}`,
),
);
expect(offenders).toEqual([]);
});
describe("the matchers fire", () => {
it("on a glob, a regex and a non-literal pattern", () => {
expect(
apiStubsIn(`await page.route("**/api/measurements*", f);`),
).toHaveLength(1);
expect(
apiStubsIn(`await page.route(/\\/api\\/analytics(\\?|$)/, f);`),
).toHaveLength(1);
expect(apiStubsIn(`await context.route(PATTERN, f);`)).toHaveLength(1);
expect(
apiStubsIn(`await page.routeFromHAR("api.har", { update: false });`),
).toHaveLength(1);
});
it("not on a third-party host, a comment, or an unrelated call", () => {
expect(
apiStubsIn(`await page.route("https://tiles.example.org/**", f);`),
).toEqual([]);
expect(apiStubsIn(`// page.route("**/api/x", f)`)).toEqual([]);
expect(apiStubsIn(`router.push("/api/x");`)).toEqual([]);
});
it("on a stubbing helper imported by name, and not on its siblings", () => {
const helper = [
`export function mockThings(page) {`,
` void page.route("**/api/things**", (r) => r.fulfill({}));`,
`}`,
`export async function openThing(page) {`,
` await page.goto("/things");`,
`}`,
].join("\n");
expect(stubbingExports(helper)).toEqual(["mockThings"]);
const read = () => helper;
const uses = `import { mockThings, openThing } from "./medications-wizard-helpers";`;
expect(findApiStubs("x.spec.ts", uses, read)).toHaveLength(1);
const clean = `import { openThing } from "./medications-wizard-helpers";`;
expect(findApiStubs("x.spec.ts", clean, read)).toEqual([]);
});
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* The dashboard's source, for the guards that pin how it is wired.
*
* The dashboard is a composer (`src/app/page-client.tsx`) over a tile
* registry (`src/components/dashboard/tiles/`): the reads, the tile context,
* the strip and chart definitions and the rendering of both rows each live in
* their own module there. A guard that used to read the one page file reads
* all of them through here, so a rule about the dashboard holds wherever in
* the registry the code it watches sits, and moving code between the files
* cannot turn a negative assertion green. The folder read carries a floor,
* and the page read an anchor, so a rename fails loudly instead of returning
* less text.
*/
import { readSource, readSourceFolder } from "./source-files";
export const DASHBOARD_PAGE = "src/app/page-client.tsx";
export const DASHBOARD_TILES_DIR = "src/components/dashboard/tiles";
/** Each dashboard module as `[repo-relative path, text]`. */
export function dashboardSourceFiles(): Array<[string, string]> {
const page = readSource(DASHBOARD_PAGE, "DashboardPageClient");
const tiles = readSourceFolder(DASHBOARD_TILES_DIR, {
floor: 5,
include: (rel) => !rel.includes("__tests__"),
}).map(([rel, text]): [string, string] => [
`${DASHBOARD_TILES_DIR}/${rel}`,
text,
]);
return [[DASHBOARD_PAGE, page], ...tiles];
}
/** The dashboard modules' text, concatenated in a stable order. */
export function dashboardSource(): string {
return dashboardSourceFiles()
.map(([, text]) => text)
.join("\n");
}
@@ -0,0 +1,20 @@
/**
* The document detail sheet's source, for the checks that pin how it is
* wired: the sheet (`document-detail-sheet.tsx`) and the hooks and sections
* it is built from (`documents/detail/`). Read together, so a rule about the
* sheet holds wherever in those modules the code sits. The folder read has a
* floor and the sheet an anchor, so a rename fails instead of reading less.
*/
import { readSource, readSourceFolder } from "./source-files";
export function documentDetailSource(): string {
return [
readSource(
"src/components/documents/document-detail-sheet.tsx",
"DocumentDetailSheet",
),
...readSourceFolder("src/components/documents/detail", { floor: 4 }).map(
([, text]) => text,
),
].join("\n");
}
@@ -151,7 +151,7 @@ describe("the intake confirmation is decided in one place", () => {
expect(posting.length).toBeGreaterThan(2);
for (const rel of [
"components/medications/use-medication-intake.ts",
"components/medications/dose-history-ledger.tsx",
"components/medications/dose-history/use-dose-history-ledger.ts",
"components/dashboard/medication-intake-quick-add.tsx",
"components/medications/dose-history-add-dialog.tsx",
]) {
@@ -141,7 +141,7 @@ const TS_READERS: Record<string, Entry> = {
exempt:
"means several readings of a type the day route sends one by one (weight, blood pressure, glucose); pulse arrives as one server-side day value (`dayValueShape` in lib/day/values.ts)",
},
"components/charts/health-chart.tsx": {
"components/charts/health-chart/series-math.ts": {
exempt:
"a rolling average over the points the chart draws: display smoothing, not a stated figure",
},
@@ -107,6 +107,18 @@ function framedRowsInCards(source: string): number[] {
return lines;
}
/**
* Read failures that draw their own markup on purpose, each with its reason.
* Keyed by file, and held live by the case below: an entry whose file no
* longer hand-rolls a branch fails rather than lingering as a blind spot.
*/
const HAND_ROLLED_BY_DESIGN = new Map<string, string>([
[
"components/admin/backups/restore-dialog.tsx",
"the restore preview never blocks the restore: a failed preview states its own absence in the dialog, and names the missing key when that is why",
],
]);
const files = walk(SRC).map((file) => ({
file: relative(SRC, file),
source: readFileSync(file, "utf8"),
@@ -143,12 +155,24 @@ describe("read failures render through a primitive", () => {
it("never hand-rolls one beside a read", () => {
const offenders = files.flatMap(({ file, source }) =>
readErrorBranches(source)
.filter((b) => b.handRolled)
.map((b) => `${file}:${b.line}`),
HAND_ROLLED_BY_DESIGN.has(file)
? []
: readErrorBranches(source)
.filter((b) => b.handRolled)
.map((b) => `${file}:${b.line}`),
);
expect(offenders).toEqual([]);
});
it("keeps every deliberate exception live", () => {
const stale = [...HAND_ROLLED_BY_DESIGN.keys()].filter((name) => {
const entry = files.find(({ file }) => file === name);
return (
!entry || !readErrorBranches(entry.source).some((b) => b.handRolled)
);
});
expect(stale).toEqual([]);
});
});
describe("an error row inside a card draws no frame of its own", () => {
+17 -5
View File
@@ -1,10 +1,25 @@
import { readFile } from "node:fs/promises";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
/** The managed settings section and every family form it composes. */
async function readManagedSettingsView(): Promise<string> {
const folder = fromRoot("src/components/settings/managed-record");
const forms = (await readdir(folder)).filter((name) => name.endsWith(".tsx"));
expect(forms.length).toBeGreaterThanOrEqual(7);
const sources = await Promise.all([
readFile(
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
"utf8",
),
...forms.map((name) => readFile(resolve(folder, name), "utf8")),
]);
return sources.join("\n");
}
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
if (
expected === null ||
@@ -40,10 +55,7 @@ describe("managed record settings UI", () => {
fromRoot("src/components/settings/managed-integration-status.tsx"),
"utf8",
),
readFile(
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
"utf8",
),
readManagedSettingsView(),
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
readFile(fromRoot("src/app/settings/layout/[module]/page.tsx"), "utf8"),
readFile(
@@ -144,7 +144,7 @@ const RULES: ResolvedFieldRule[] = [
allow: {
"src/hooks/use-module-toggle.ts":
"the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes",
"src/components/settings/managed-record-settings-section.tsx":
"src/components/settings/managed-record/coach-form.tsx":
"the managed record's own settings form: the field's default is the record's stored opt-out",
"src/hooks/use-auth.ts":
"the `/me` payload normaliser: coerces the field against a stale payload, decides nothing",
@@ -346,7 +346,10 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
field: "GET /api/dashboard/snapshot tiles.directions",
props: ["insights/schemas.ts#directions"],
readers: [
{ file: "src/app/page-client.tsx", pattern: /tiles\.directions/ },
{
file: "src/components/dashboard/tiles/tile-context.ts",
pattern: /tiles\.directions/,
},
],
},
{
+4 -1
View File
@@ -92,7 +92,10 @@ describe("source size ratchet", () => {
it("reads the whole tree", () => {
expect(m.files).toBeGreaterThan(3000);
expect(m.functionsSeen).toBeGreaterThan(20_000);
requireFloor("files over the cap", Object.keys(m.fileLines), 100);
// The floor follows the ratchet down: 115 files sat over the cap before
// the large-component splits, 96 after. It guards the matcher, so it sits
// under the real count, not at it.
requireFloor("files over the cap", Object.keys(m.fileLines), 80);
requireFloor("functions over the cap", Object.keys(m.functionLines), 300);
});
@@ -319,7 +319,7 @@ function findGatedWebCalls(): GatedCall[] {
const PRECHECKED = new Set<string>([
// `upload.mutationFn` asks first, then calls `uploadOnce`, so a large file
// is not sent once only to be refused.
"components/admin/backups-section.tsx -> POST /api/admin/backups/upload",
"components/admin/backups/use-backup-upload.ts -> POST /api/admin/backups/upload",
]);
describe("fresh-proof gates on the web", () => {
+28 -8
View File
@@ -97,7 +97,10 @@ const PINNED_AFFORDANCES: Record<
"src/components/admin/assistant-section.tsx": { "toast.success": 1 },
// v1.43 — the AI usage bounds: a settings save, not a write with counts.
"src/components/admin/ai-usage-section.tsx": { "toast.success": 1 },
"src/components/admin/backups-section.tsx": { "toast.success": 4 },
// The run, the download, the upload and the finished restore, one toast each.
"src/components/admin/backups/use-backup-actions.ts": { "toast.success": 2 },
"src/components/admin/backups/use-backup-restores.ts": { "toast.success": 1 },
"src/components/admin/backups/use-backup-upload.ts": { "toast.success": 1 },
"src/components/admin/central-codex-section.tsx": { "toast.success": 2 },
"src/components/admin/danger-zone-section.tsx": { "text-success": 1 },
"src/components/admin/encryption-section.tsx": {
@@ -176,8 +179,18 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/dashboard/range-display.tsx": { "text-success": 2 },
"src/components/documents/document-card.tsx": { "text-success": 1 },
"src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 },
"src/components/documents/documents-view.tsx": { "toast.success": 3 },
"src/components/documents/detail/use-document-detail-ai.ts": {
"toast.success": 1,
},
"src/components/documents/detail/use-document-detail.ts": {
"toast.success": 1,
},
"src/components/documents/vault/use-vault-library.ts": {
"toast.success": 1,
},
"src/components/documents/vault/use-vault-bulk.ts": {
"toast.success": 2,
},
// The document picker's per-row result: tinted only for the `imported` /
// `duplicate` outcome the import route returned for that one document, i.e.
// a document the vault now holds. The run's headline goes through
@@ -228,12 +241,14 @@ const PINNED_AFFORDANCES: Record<
"src/components/measurement-reminders/vorsorge-dashboard-card.tsx": {
CheckCircle2: 2,
},
"src/components/measurement-reminders/vorsorge-section.tsx": {
"src/components/measurement-reminders/checkups/checkup-card.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/measurements/measurement-form.tsx": { "toast.success": 1 },
"src/components/measurements/measurement-list.tsx": { "toast.success": 3 },
"src/components/measurements/list/use-measurement-list-data.ts": {
"toast.success": 3,
},
"src/components/medications/card-parts/medication-status-pill.tsx": {
"text-success": 1,
},
@@ -241,8 +256,13 @@ const PINNED_AFFORDANCES: Record<
"src/components/medications/dose-history-add-dialog.tsx": {
"toast.success": 2,
},
"src/components/medications/dose-history-ledger.tsx": {
"text-success": 2,
"src/components/medications/dose-history/ledger-row-item.tsx": {
"text-success": 1,
},
"src/components/medications/dose-history/ledger-summary-chart.tsx": {
"text-success": 1,
},
"src/components/medications/dose-history/use-dose-history-ledger.ts": {
"toast.success": 4,
},
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
@@ -348,7 +368,7 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
"src/components/settings/dashboard-layout-section.tsx": {
"src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts": {
"toast.success": 2,
},
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
@@ -85,6 +85,9 @@ const HEIGHT_CONTROL = join(
function sweptFiles(): string[] {
return [
join(SRC, "app", "page-client.tsx"),
...walkSourceFiles(join(SRC, "components", "dashboard", "tiles"), {
floor: 5,
}).map((rel) => join(SRC, "components", "dashboard", "tiles", rel)),
...walkSourceFiles(SRC, { floor: 700, extensions: [".tsx"] })
.filter(
(rel) => rel.startsWith("app/insights/") && rel.endsWith("/page.tsx"),
@@ -295,10 +298,15 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason:
"input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save",
},
"components/measurements/measurement-list.tsx": {
lines: 2,
"components/measurements/list/use-measurement-edit.ts": {
lines: 1,
reason: "input parsing: the inline edit inverts an mmol/L entry to mg/dL",
},
"components/measurements/list/use-measurement-row-display.ts": {
lines: 1,
reason:
"input parsing: decides whether the inline edit's input is in mmol/L and so needs inverting",
},
"components/settings/import-panel/import-examples.ts": {
lines: 3,
reason: "input parsing: CSV examples showing the units an import accepts",
@@ -12,6 +12,12 @@
* same suite: every dependency bump failed on a launch error that said nothing
* about the bump. One workflow was fixed, one class was not.
*
* Since v1.46 the suite also carries the `browser` project
* (`*.browser.test.tsx`, see `tests/browser/`), which launches the same
* headless shell through Vitest's Playwright provider for every interaction
* test. A slice without the browser now fails on many files rather than one;
* the install it needs is the same, so this guard covers both.
*
* The sweep is textual and file-scoped, which is its limit: a workflow with two
* jobs, only one of which installs the browser, satisfies it. It catches the
* thing that actually went wrong — a whole workflow running the suite with no
@@ -58,8 +64,8 @@ describe("the browser the unit suite measures with", () => {
expect(
missing,
"a workflow runs the unit suite without installing the headless " +
"Chromium the hero-geometry check measures with — that check refuses " +
"to skip under CI, so the job fails on a launch error instead",
"Chromium the hero-geometry check and the browser project launch — " +
"neither skips under CI, so the job fails on a launch error instead",
).toEqual([]);
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ const ALLOWED: Record<string, string> = {
"lib/url-state.ts": "the helper itself",
"components/clinician/share-unlock-gate.tsx":
"scrubs a passphrase from the fragment synchronously, before the unlock request leaves; no query state",
"components/documents/documents-view.tsx":
"components/documents/vault/selection-history.ts":
"closeDocumentSelectionHistoryEntry takes a history-shaped argument for its tests; the page passes `urlStateHistory`",
};
@@ -36,7 +36,7 @@ const HOME_IMPORT = 'from "@/lib/measurement-reminders/due-day"';
* shared body, that is the regression, not a reason to relax the test.
*/
const RENDERING_SURFACES = [
"components/measurement-reminders/vorsorge-section.tsx",
"components/measurement-reminders/checkups/checkup-card.tsx",
"components/measurement-reminders/vorsorge-dashboard-card.tsx",
];
@@ -2,67 +2,54 @@
* The dashboard weight tile colours its trend by the resolved target
* direction, never by a literal (#1006).
*
* Every other tile on the strip passes a literal `directionSentiment`, because
* for resting pulse or steps the good direction is a property of the metric.
* For resting pulse or steps the good direction is a property of the metric.
* Weight is the exception: which way is progress depends on the person's own
* target, and it is resolved on the server (`tiles.weightTrend` on the
* snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` here is
* exactly the defect the issue reported — a person below their target saw
* every gain coloured as a setback — and it would type-check and render
* without complaint, so this pins the attribute rather than the pixels.
* snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` is
* exactly the defect the issue reported (a person below their target saw
* every gain coloured as a setback), and it would type-check and render
* without complaint.
*
* Structural rather than rendered because the dashboard page early-returns a
* loading gate under SSR, and the property is about the source: the weight
* `<TrendCard>` names `weightTrend.direction`, or it does not.
* The tile is built from the registry with each resolved direction in turn,
* and the card has to carry the one it was handed.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import type { ReactElement } from "react";
import { describe, expect, it } from "vitest";
const PAGE = join(process.cwd(), "src/app/page-client.tsx");
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
function weightTileDirections(): string[] {
const text = readFileSync(PAGE, "utf8");
const file = ts.createSourceFile(
PAGE,
text,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const found: string[] = [];
const visit = (node: ts.Node) => {
if (
ts.isJsxSelfClosingElement(node) &&
node.tagName.getText(file) === "TrendCard"
) {
const attrs = node.attributes.properties.filter(ts.isJsxAttribute);
const key = attrs.find((a) => a.name.getText(file) === "key");
const isWeight =
key?.initializer &&
ts.isStringLiteral(key.initializer) &&
key.initializer.text === "weight";
if (isWeight) {
const direction = attrs.find(
(a) => a.name.getText(file) === "directionSentiment",
);
found.push(direction?.initializer?.getText(file) ?? "<absent>");
}
}
ts.forEachChild(node, visit);
};
visit(file);
return found;
function weightCards(direction: string) {
const ctx = {
t: (key: string) => key,
fmt: { number: (v: number) => String(v) },
td: (_type: string, v: number | null | undefined) => v ?? null,
tdd: (_type: string, v: number | null | undefined) => v ?? null,
unitDisplay: { unitFor: () => "kg" },
summaries: { WEIGHT: { count: 3, latest: 80, avg7: 80, avg30: 81 } },
weightRange: null,
weightHintRange: null,
weightTrend: { direction },
compareBaseline: "none",
tileCompareDelta: () => null,
tileStaleDays: () => null,
directionOf: () => "up-bad",
} as unknown as DashboardTileContext;
const tiles = STRIP_TILES.filter((tile) => tile.widget === "weight");
return tiles.flatMap((tile) => tile.cards(ctx));
}
describe("dashboard weight tile direction", () => {
it("finds exactly one weight tile, so an empty match cannot pass", () => {
expect(weightTileDirections()).toHaveLength(1);
expect(weightCards("down-good")).toHaveLength(1);
});
it("reads the resolved target direction rather than a literal", () => {
expect(weightTileDirections()).toEqual(["{weightTrend.direction}"]);
for (const direction of ["down-good", "up-good", "neutral"]) {
const [card] = weightCards(direction);
const props = (card.node as ReactElement<{ directionSentiment: string }>)
.props;
expect(props.directionSentiment).toBe(direction);
}
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
/**
* v1.4.39.2 — dashboard analytics slim/thick split.
@@ -26,16 +25,10 @@ import { join } from "node:path";
* slim/thick split still drives the page, so the protective intent
* holds; the matchers only relaxed to tolerate the `enabled` arg.
*/
const ROOT = join(__dirname, "../../..");
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.39.2 — dashboard analytics slim/thick split", () => {
it("mounts both the slim slice and the thick slice in parallel", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// Slim slice — `?slice=summaries` paints the per-type tile strip.
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/);
// Thick slice — the no-slice `useAnalyticsQuery` feeds the
@@ -45,7 +38,7 @@ describe("v1.4.39.2 — dashboard analytics slim/thick split", () => {
});
it("merges slim and thick results so call-sites stay shape-stable", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// The merge contract: slim wins on overlapping fields, thick fills
// in `bpInTargetPct*` and `glucoseByContext`. The text anchors
// protect against a future refactor accidentally dropping one of
@@ -21,11 +21,17 @@
* `resolveChartRevealState` resolver, the markup through
* `renderToStaticMarkup`, and the page wiring through textual pins.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ReactElement } from "react";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import {
CHART_TILES,
revealChartIds,
type ChartTileContext,
} from "@/components/dashboard/tiles/chart-tiles";
import {
CHART_REVEAL_TIMEOUT_MS,
DashboardChartCell,
@@ -34,8 +40,6 @@ import {
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { I18nProvider } from "@/lib/i18n/context";
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
describe("resolveChartRevealState", () => {
it("stays hidden while the gate is not armed (no visible charts yet)", () => {
expect(
@@ -145,46 +149,65 @@ describe("TrendCardSkeleton", () => {
});
});
describe("dashboard chart-reveal wiring (page.tsx)", () => {
const src = readFileSync(PAGE_PATH, "utf8");
describe("dashboard chart-reveal wiring (the chart registry)", () => {
const GATED = [
"weight-chart",
"bmi-chart",
"bp-chart",
"pulse-chart",
"bodyFat-chart",
"mood-chart",
"sleep-chart",
"steps-chart",
"medications",
];
it("derives the gated id list from the chart visibility gates", () => {
expect(src).toMatch(/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/);
expect(src).toMatch(/useDashboardChartReveal\(revealChartIds\)/);
it("derives the gated id list from the visible chart cards", () => {
expect(revealChartIds(CHART_TILES)).toEqual(GATED);
// A hidden card drops out of the list the reveal waits for.
expect(
revealChartIds(CHART_TILES.filter((tile) => tile.id !== "bp-chart")),
).toEqual(GATED.filter((id) => id !== "bp-chart"));
expect(dashboardSource()).toMatch(
/useDashboardChartReveal\(revealChartIds\(chartTiles\)\)/,
);
});
it("every data-backed chart reports through onDataReady", () => {
for (const id of [
"weight-chart",
"bmi-chart",
"bp-chart",
"pulse-chart",
"bodyFat-chart",
"mood-chart",
"sleep-chart",
"steps-chart",
"medications",
]) {
expect(src).toContain(`onDataReady={() => markChartReady("${id}")}`);
const markChartReady = vi.fn();
const ctx = {
t: (key: string) => key,
unitDisplay: {
unitFor: () => "kg",
transformFor: () => ({ factor: 1, offset: 0 }),
},
user: { heightCm: 180, timezone: "Europe/Berlin" },
summaries: {},
compareBaseline: "none",
preloadedSeries: undefined,
markChartReady,
} as unknown as ChartTileContext;
for (const tile of CHART_TILES.filter((t) => GATED.includes(t.id))) {
const node = tile.render(ctx) as ReactElement<{
onDataReady?: () => void;
}>;
node.props.onDataReady?.();
expect(markChartReady).toHaveBeenLastCalledWith(tile.id);
}
expect(markChartReady).toHaveBeenCalledTimes(GATED.length);
});
it("gated entries render inside DashboardChartCell", () => {
expect(src).toMatch(
expect(dashboardSource()).toMatch(
/entry\.revealGated\s*\?[\s\S]*?<DashboardChartCell revealed=\{chartsRevealed\}>/,
);
});
it("self-skeletoning cards (achievements / workouts) stay outside the gate", () => {
// Their entries never set `revealGated`, so the ungated branch
// renders them directly.
const achievements = src.match(
/charts\.push\(\{\s*id:\s*"achievements"[\s\S]*?\}\);/,
);
const workouts = src.match(
/charts\.push\(\{\s*id:\s*"recentWorkouts"[\s\S]*?\}\);/,
);
expect(achievements?.[0]).not.toContain("revealGated");
expect(workouts?.[0]).not.toContain("revealGated");
for (const id of ["achievements", "recentWorkouts", "vorsorge"]) {
const tile = CHART_TILES.find((t) => t.id === id);
expect(tile, id).toBeDefined();
expect(tile?.revealGated, id).toBeUndefined();
}
});
});
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -13,7 +14,6 @@ import { join } from "node:path";
* client mounts the checklist only without it.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
const CLIENT_PATH = join(process.cwd(), "src/app/page-client.tsx");
describe("dashboard setup checklist on a demo instance", () => {
it("threads DEMO_MODE into every render of the dashboard client", () => {
@@ -27,7 +27,7 @@ describe("dashboard setup checklist on a demo instance", () => {
});
it("mounts the checklist only outside demo mode", () => {
const src = readFileSync(CLIENT_PATH, "utf8");
const src = dashboardSource();
const mounts = src.match(/<GettingStartedChecklist\b[^>]*\/>/g) ?? [];
expect(mounts).toHaveLength(1);
expect(src).toContain("{demoMode ? null : <GettingStartedChecklist />}");
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -16,14 +17,13 @@ import { join } from "node:path";
* once carried it — is gone), while the page itself derives no
* welcome text.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
const HEADER_PATH = join(
process.cwd(),
"src/components/dashboard/dashboard-header.tsx",
);
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
it("mounts the TodayHero above the tile strip", () => {
const heroIdx = src.indexOf("<TodayHero");
@@ -80,7 +80,7 @@ describe("dashboard hero — header greeting", () => {
});
it("the page mounts the header without a greeting prop", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toContain(
"<DashboardHeader onQuickEntry={setQuickEntryDialog} />",
);
@@ -1,3 +1,4 @@
import type { ReactElement } from "react";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -19,7 +20,8 @@ import {
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { locales } from "@/lib/i18n/config";
import type { DataSummary } from "@/lib/analytics/trends";
import { readSource } from "@/__tests__/helpers/source-files";
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
/**
* The dashboard HRV tile must accept either HRV type.
@@ -245,20 +247,37 @@ describe("the tile the user actually sees", () => {
}
});
it("does not read freshness off a hardcoded SDNN type", () => {
// The one source-level check kept, and deliberately a NEGATIVE one: it
// names the exact expression the bug lived on rather than asserting the
// current formatting of the replacement, so reformatting cannot break
// it. Freshness is per-type and captioning a live RMSSD tile from the
// SDNN lookup is the failure it guards.
const page = readSource("src/app/page-client.tsx", [
"const tileStaleDays",
"HEART_RATE_VARIABILITY",
]);
expect(page).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")');
expect(page).not.toContain(
"const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY",
);
it("reads freshness off the series the tile shows", () => {
// Freshness is per type, and captioning a live RMSSD tile from the SDNN
// lookup marked it permanently stale. The registry's HRV tile is built
// with a context whose freshness differs per type, so the caption it
// asks for names the type it used.
const hrvTile = STRIP_TILES.find((tile) => tile.widget === "hrv");
expect(hrvTile).toBeDefined();
for (const [summaries, type, days] of [
[{ HRV_RMSSD: summary(30, 42) }, "HRV_RMSSD", 12],
[
{ HEART_RATE_VARIABILITY: summary(9, 55) },
"HEART_RATE_VARIABILITY",
20,
],
] as const) {
const ctx = {
t: (key: string) => key,
summaries,
hrv: pickHrvSummary(summaries),
compareBaseline: "none",
directionOf: () => "neutral",
tileCompareDelta: () => null,
tileStaleDays: (t: string | null | undefined) =>
t === "HRV_RMSSD" ? 12 : t === "HEART_RATE_VARIABILITY" ? 20 : null,
} as unknown as DashboardTileContext;
expect(hrvTile?.visible(ctx), type).toBe(true);
const [card] = hrvTile?.cards(ctx) ?? [];
const props = (card.node as ReactElement<{ staleDays: number | null }>)
.props;
expect(props.staleDays, type).toBe(days);
}
});
});
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { readSource } from "@/__tests__/helpers/source-files";
/**
* v1.4.40 — dashboard Suspense boundaries.
@@ -22,22 +22,16 @@ import { join } from "node:path";
* a future refactor that drops the boundary lands a failing test
* rather than silently regressing the streaming-composition contract.
*/
const ROOT = join(__dirname, "../../..");
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("imports Suspense from React", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
expect(src).toMatch(/from\s+"react"/);
expect(src).toMatch(/\bSuspense\b/);
});
it("wraps the chart-row cell in a `<Suspense fallback={<ChartSkeleton />}>` boundary", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// `<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>`
// exact contract: the chart row's per-cell fallback uses the same
// ChartSkeleton primitive the dynamic-loaded charts already paint
@@ -48,7 +42,7 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
});
it("wraps each tile-strip cell in a `<Suspense>` boundary with a layout-stable placeholder", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null`
// fallback for an `aria-hidden` placeholder div; v1.16.0 upgraded
// it to the structured `<TrendCardSkeleton>` silhouette (the
@@ -64,18 +58,20 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
});
it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => {
const src = load(PAGE_PATH);
// Module-scope declaration — search outside the component body.
// Pre-fix the const was declared inside `DashboardPage` so every
// render created a fresh `{}` reference.
const moduleSliceEnd = src.indexOf("export default function DashboardPage");
const src = readSource(
"src/components/dashboard/tiles/use-dashboard-data.ts",
"export function useDashboardData",
);
// Module-scope declaration — search outside the hook body. Declared
// inside it, every render would create a fresh `{}` reference.
const moduleSliceEnd = src.indexOf("export function useDashboardData");
expect(moduleSliceEnd).toBeGreaterThan(0);
const moduleSlice = src.slice(0, moduleSliceEnd);
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// moved off the page with the greeting itself: the greeting now
// lives in the header (`dashboard-header.tsx`), so the page no
@@ -1,12 +1,11 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
resolveChartRowPlaceholderCount,
resolveConfiguredTileCount,
resolveDashboardFirstPaintGate,
} from "../page-client";
} from "@/components/dashboard/dashboard-gates";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import {
DEFAULT_DASHBOARD_LAYOUT,
type DashboardLayout,
@@ -33,7 +32,6 @@ import {
* snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the
* behaviour; textual pins keep the wiring honest.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
describe("resolveDashboardFirstPaintGate", () => {
const base = {
@@ -173,14 +171,14 @@ describe("resolveChartRowPlaceholderCount (v1.16.8)", () => {
describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
it("derives `configuredTileCount` through the tile-capable resolver", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/const\s+configuredTileCount\s*=\s*resolveConfiguredTileCount\(layout\);/,
);
});
it("reserves the chart row while the primary query loads", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
// The placeholder count keys off the layout config minus the
// already-mounted (layout-gated) cells, and only while loading.
// `mounted &&` pins the hydration render to the SSR output — the
@@ -189,7 +187,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
expect(src).toMatch(
/const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*mounted\s*&&\s*Boolean\(user\?\.heightCm\)/,
);
expect(src).toMatch(/-\s*charts\.length/);
expect(src).toMatch(/-\s*chartEntries\.length/);
// The reserved cells render as aria-hidden ChartSkeletons in their
// own slot.
const skeletonBlock = src.match(
@@ -201,7 +199,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
});
it("derives `primaryLoading` from the snapshot vs slim query", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
// v1.16.4 — `!mounted` pins SSR + the hydration render to the
// skeleton branch so a late-hydrating boundary can't disagree with
// the server HTML (React #418); see `useMounted`.
@@ -211,21 +209,21 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
});
it("routes the gate through `resolveDashboardFirstPaintGate`", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/resolveDashboardFirstPaintGate\(\{[\s\S]*?primaryLoading,[\s\S]*?\}\)/,
);
});
it("renders the skeleton with the same auto-fit grid track the real strip uses", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/showTileStripSkeleton\s*&&\s*\(\s*<div[\s\S]*?\[grid-template-columns:repeat\(auto-fit,minmax\(min\(100%,11rem\),1fr\)\)\]/,
);
});
it("keys the skeleton card count off configuredTileCount", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/Array\.from\(\{\s*length:\s*configuredTileCount\s*\}\)/,
);
@@ -237,7 +235,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
// Reduced motion moved into the component (the `<Skeleton>` primitive
// carries `motion-reduce:animate-none`); the per-component test in
// `dashboard-chart-reveal.test.tsx` pins that.
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
const skeletonBlock = src.match(
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
);
@@ -248,12 +246,12 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
});
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/);
});
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(/if\s*\(showEmptyState\)\s*\{/);
});
});
+37 -12
View File
@@ -1,7 +1,12 @@
import type { ReactElement } from "react";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
/**
* v1.4.19 — guards for the polish pass on `/insights` and the
* dashboard:
@@ -28,7 +33,6 @@ import { join } from "node:path";
*/
const ROOT = join(__dirname, "../../..");
const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
@@ -38,7 +42,7 @@ function load(path: string): string {
describe("v1.4.19 A3 — dashboard polish", () => {
it("dashboard page does NOT render <CompareToggle />", () => {
const src = load(DASHBOARD_PATH);
const src = dashboardSource();
// The component import + JSX use are both gone. The toggle still
// lives on /insights via a separate import there.
expect(src).not.toMatch(/<CompareToggle\b/);
@@ -52,9 +56,9 @@ describe("v1.4.19 A3 — dashboard polish", () => {
// baseline plumbing along with the toggle, every chart on the
// dashboard would lose its comparison overlay silently. The
// resolved layout still carries the value to each chart.
const src = load(DASHBOARD_PATH);
expect(src).toContain("comparisonBaseline");
expect(src).toContain("compareBaseline");
const src = dashboardSource();
expect(src).toContain("layout.comparisonBaseline");
expect(src).toContain("compareBaseline={ctx.compareBaseline}");
});
});
@@ -203,13 +207,34 @@ describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta us
* window (priorMonth / priorYear) instead of all-time.
*/
it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => {
const src = load(DASHBOARD_PATH);
// The fix introduces `bpComparePrior` keyed by `compareBaseline`.
expect(src).toContain("bpInTargetPctPriorMonth");
expect(src).toContain("bpInTargetPctPriorYear");
// The all-time shortcut from v1.4.22 A2 must not subtract `bpAll`
// for the comparison delta any more.
expect(src).not.toMatch(/bp30\s*-\s*bpAll/);
// The tile is built from the registry with a 30-day share of 80 % and
// prior periods of 70 % (month), 50 % (year) and 40 % (all time): the
// delta follows the chosen baseline and never the all-time share.
const tile = STRIP_TILES.find((t) => t.widget === "bpInTarget");
const deltaFor = (compareBaseline: string) => {
const ctx = {
t: (key: string) => key,
compareBaseline,
directionOf: () => "neutral",
data: {
bpInTargetPct: 75,
bpInTargetPct7d: 78,
bpInTargetPct30d: 80,
bpInTargetPctAllTime: 40,
bpInTargetPctPriorMonth: 70,
bpInTargetPctPriorYear: 50,
bpInTargetCount90: 200,
bpInTargetSpanDays90: 90,
},
} as unknown as DashboardTileContext;
expect(tile?.visible(ctx)).toBe(true);
const [card] = tile?.cards(ctx) ?? [];
return (card.node as ReactElement<{ compareDelta: number | null }>).props
.compareDelta;
};
expect(deltaFor("lastMonth")).toBe(10);
expect(deltaFor("lastYear")).toBe(30);
expect(deltaFor("none")).toBeNull();
});
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
@@ -1,3 +1,4 @@
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
@@ -86,10 +87,7 @@ describe("RootLoading — single skeleton source (wiring pins)", () => {
join(process.cwd(), "src/app/loading.tsx"),
"utf8",
);
const pageSrc = readFileSync(
join(process.cwd(), "src/app/page-client.tsx"),
"utf8",
);
const pageSrc = dashboardSource();
it("imports the same silhouette components the page's loading phase uses", () => {
expect(loadingSrc).toContain(
+96 -2231
View File
File diff suppressed because it is too large. Load diff
@@ -185,7 +185,7 @@ import { admittedQuickEntry } from "@/components/dashboard/quick-entry-sheets";
import { TodayHero } from "@/components/daily/today-hero";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { LedgerRowItem } from "@/components/medications/dose-history-ledger";
import { LedgerRowItem } from "@/components/medications/dose-history/ledger-row-item";
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
import { VisitsSection } from "@/components/encounters/visits-section";
import type { EncounterDTO } from "@/lib/encounters/dto";
@@ -84,13 +84,16 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { BackupsSection, restorePreviewFailure } from "../backups-section";
import {
RestorePreview,
restorePreviewFailure,
} from "../backups/restore-dialog";
describe("restore preview query", () => {
it("is never retried and waits a bounded time", async () => {
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BackupsSection />
<RestorePreview backupId="b1" open />
</I18nProvider>,
);
const key = JSON.stringify(queryKeys.adminBackupSummary("b1"));
@@ -29,7 +29,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import {
RestoreRefusalNotice,
missingSectionsOf,
} from "@/components/admin/backups-section";
} from "@/components/admin/backups/restore-notices";
import type { MissingBackupSection } from "@/lib/export/restore-skips";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
@@ -22,7 +22,7 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RestoreSkipReport } from "@/components/admin/backups-section";
import { RestoreSkipReport } from "@/components/admin/backups/restore-notices";
import type { RestoreSkipSummary } from "@/lib/export/restore-skips";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
@@ -17,10 +17,6 @@ const healthySchedule = {
};
const mocks = vi.hoisted(() => ({
mutationOptions: [] as Array<{
onSuccess?: (data: { summary: Record<string, number> }) => void;
}>,
toastSuccess: vi.fn(),
// Mutable so one file can render both a healthy schedule and a broken one;
// the query mock reads it on every render.
queryData: { rows: [], retentionDays: 30 } as Record<string, unknown>,
@@ -43,7 +39,7 @@ vi.mock("next/navigation", () => ({
vi.mock("sonner", () => ({
toast: {
success: mocks.toastSuccess,
success: vi.fn(),
error: vi.fn(),
},
}));
@@ -55,21 +51,14 @@ vi.mock("@tanstack/react-query", () => ({
isError: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: (options: unknown) => {
mocks.mutationOptions.push(
options as {
onSuccess?: (data: { summary: Record<string, number> }) => void;
},
);
return {
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
variables: undefined,
};
},
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
variables: undefined,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -83,6 +72,7 @@ vi.mock("@/hooks/use-auth", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { BackupsSection } from "../backups-section";
import { uploadedRecordCount } from "../backups/use-backup-upload";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
@@ -224,26 +214,16 @@ describe("BackupsSection — schedule health", () => {
});
});
describe("BackupsSection — upload outcome", () => {
describe("backup upload outcome", () => {
it("includes health profile fact revisions in the restored record total", () => {
mocks.toastSuccess.mockClear();
const firstMutation = mocks.mutationOptions.length;
render();
const uploadOutcome = mocks.mutationOptions[firstMutation + 1]?.onSuccess;
expect(uploadOutcome).toBeTypeOf("function");
uploadOutcome!({
summary: {
expect(
uploadedRecordCount({
measurements: 1,
medications: 2,
intakeEvents: 3,
moodEntries: 4,
healthProfileFactRevisions: 5,
},
});
expect(mocks.toastSuccess).toHaveBeenCalledWith(
expect.stringContaining("15"),
);
}),
).toBe(15);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,185 @@
import { Download, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ListRow } from "@/components/ui/list-row";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BackupRow } from "@/types/backups";
import { RestoreRowDialog } from "./restore-dialog";
/** What a row's Download and Restore controls act through. */
export type BackupRowActionProps = {
downloadingId: string | null;
onDownload: (row: BackupRow) => void;
restorePending: (row: BackupRow) => boolean;
onRestore: (
row: BackupRow,
options: { restoreInstanceSettings: boolean },
) => void;
};
/**
* Render the `DataBackup.type` enum (`WEEKLY_AUTO` / `MANUAL`) as a
* human label. Unknown values fall through to the raw enum so a future
* type added in the schema is still legible at a glance.
*/
function formatBackupType(
type: string,
t: ReturnType<typeof useTranslations>["t"],
) {
if (type === "WEEKLY_AUTO") return t("admin.section.backups.typeWeeklyAuto");
if (type === "MANUAL") return t("admin.section.backups.typeManual");
return type;
}
/** Download and Restore for one copy; the same pair in both layouts. */
function BackupRowActions({
row,
downloadingId,
onDownload,
restorePending,
onRestore,
}: BackupRowActionProps & { row: BackupRow }) {
const { t } = useTranslations();
return (
<>
<Button
size="sm"
variant="outline"
data-testid="backup-download"
disabled={downloadingId === row.id}
onClick={() => onDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<RestoreRowDialog
row={row}
pending={restorePending(row)}
onConfirm={(options) => onRestore(row, options)}
/>
</>
);
}
/**
* Wide layout: the canonical table. Hidden below `md`, where the five
* columns push Download / Restore off-screen behind a horizontal scroll; the
* card list takes over there.
*/
export function BackupRowsTable({
rows,
...actions
}: BackupRowActionProps & { rows: BackupRow[] }) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
return (
<div className="relative hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colType")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colSize")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colCreatedAt")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colActions")}
</th>
</tr>
</thead>
{/* `backup-rows` names the read rows. The three branches above
are a spinner, a load error and an empty state, so the
marker cannot stand for a page that has not answered yet.
The narrow-layout list below carries it too, so a wait on
it holds at any viewport. */}
<tbody data-slot="backup-rows" className="divide-border divide-y">
{rows.map((row, i) => (
<tr
key={row.id}
data-backup-id={row.id}
data-backup-username={row.username}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2 font-medium">{row.username}</td>
<td className="px-3 py-2">
<Badge variant="secondary" className="text-xs">
{formatBackupType(row.type, t)}
</Badge>
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{formatBytes(row.sizeBytes, locale)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{fmt.dateTime(row.createdAt)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<BackupRowActions row={row} {...actions} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* Narrow layout: each backup as a self-contained card with the meta line and
* a flex-wrapping action row, so Download / Restore stay reachable on a phone
* instead of scrolling off-screen.
*/
export function BackupRowsCards({
rows,
...actions
}: BackupRowActionProps & { rows: BackupRow[] }) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
return (
<ul
className="space-y-2 md:hidden"
data-slot="backup-rows"
data-testid="admin-backups-mobile-list"
>
{rows.map((row) => (
<ListRow asChild key={row.id} className="bg-muted/30 border-border">
<li data-backup-id={row.id} data-backup-username={row.username}>
<div className="flex items-center gap-2">
<span className="truncate font-medium">{row.username}</span>
<Badge variant="secondary" className="text-xs">
{formatBackupType(row.type, t)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.colSize")}:{" "}
{formatBytes(row.sizeBytes, locale)} ·{" "}
{fmt.dateTime(row.createdAt)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<BackupRowActions row={row} {...actions} />
</div>
</li>
</ListRow>
))}
</ul>
);
}
@@ -0,0 +1,65 @@
import { Loader2, Upload } from "lucide-react";
import type { ChangeEvent, RefObject } from "react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* The upload card: separate from the table so admins can ingest a backup file
* independently of any existing rows. The visible button proxies a hidden
* file input so the layout stays clean while keyboard and screen-reader users
* keep a labelled control.
*/
export function BackupUploadCard({
pending,
fileInputRef,
onFileChange,
}: {
pending: boolean;
fileInputRef: RefObject<HTMLInputElement | null>;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
}) {
const { t } = useTranslations();
return (
<div className="bg-muted/30 border-border flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium">
{t("admin.section.backups.uploadTitle")}
</div>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.uploadDescription")}{" "}
<span
data-slot="backup-upload-help"
className="text-muted-foreground"
>
{t("admin.section.backups.uploadHelp")}
</span>
</p>
</div>
<div>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json,application/gzip,.gz"
className="sr-only"
onChange={onFileChange}
aria-label={t("admin.section.backups.uploadButton")}
/>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => fileInputRef.current?.click()}
className="min-h-11"
>
{pending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.uploadButton")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,344 @@
import { useQuery } from "@tanstack/react-query";
import { History, Loader2 } from "lucide-react";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ApiError, apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow } from "@/types/backups";
/**
* How long the restore dialog waits for the contents of a copy. A copy's
* counts are kept with it and come back at once; this only runs out on a copy
* from an earlier version that the server is still reading whole, and stays
* under the minute a reverse proxy commonly allows a request.
*/
const PREVIEW_TIMEOUT_MS = 55_000;
/**
* What the restore dialog says when the contents did not come back: the key
* the copy needs and this server lacks, a copy from an earlier version still
* being read when the dialog stopped waiting, or anything else.
*/
export function restorePreviewFailure(
error: unknown,
): "keyMissing" | "stillReading" | "unavailable" {
if (
error instanceof ApiError &&
error.meta?.errorCode === "backup.key.missing"
) {
return "keyMissing";
}
// `AbortSignal.timeout` rejects the fetch with a DOMException of this name.
if (
typeof error === "object" &&
error !== null &&
(error as { name?: unknown }).name === "TimeoutError"
) {
return "stillReading";
}
return "unavailable";
}
/**
* The sections a restore preview names, in render order. Each
* entry sums one or more `BackupSummary` counters under one human label;
* every counter NOT named here still reaches the operator through the
* trailing "other records" line, so a future summary key can be forgotten
* here without silently vanishing from the preview.
*/
const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{
labelKey: string;
keys: readonly string[];
}> = [
{ labelKey: "previewMeasurements", keys: ["measurements"] },
{
labelKey: "previewMedications",
keys: ["medications", "intakeEvents", "medicationSideEffects"],
},
{ labelKey: "previewMood", keys: ["moodEntries"] },
{ labelKey: "previewLabs", keys: ["labResults", "biomarkers"] },
{ labelKey: "previewDocuments", keys: ["documents"] },
{ labelKey: "previewWorkouts", keys: ["workouts"] },
{ labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] },
{
labelKey: "previewVisits",
keys: ["encounters", "practitioners", "encounterLinks"],
},
{
labelKey: "previewVaccinations",
keys: ["vaccinations", "vaccinationLinks", "customVaccines"],
},
{
labelKey: "previewReminders",
keys: ["measurementReminders", "measurementReminderEvents"],
},
{
labelKey: "previewCustomMetrics",
keys: ["customMetrics", "customMetricEntries"],
},
{ labelKey: "previewHealthScores", keys: ["healthScoreRecords"] },
];
/**
* What a copy contains, fetched the moment the restore dialog opens, so the
* typed confirmation is an informed one. A failed preview never blocks the
* restore; it states its own absence instead.
*/
export function RestorePreview({
backupId,
open,
}: {
backupId: string;
open: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
// Never retried. The server answers from counts kept with the copy, at
// once; only a copy from an earlier version is read whole, and that read
// goes on (and is kept) after this request gives up. A retry would only
// start the same read a second time (#1031).
const preview = useQuery({
queryKey: queryKeys.adminBackupSummary(backupId),
queryFn: () =>
apiGet<{ summary: Record<string, unknown> }>(
`/api/admin/backups/${backupId}/summary`,
{ signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) },
),
enabled: open,
staleTime: 60_000,
retry: false,
});
const previewFailure = restorePreviewFailure(preview.error);
const numericSummary: Record<string, number> = {};
if (preview.data?.summary) {
for (const [key, value] of Object.entries(preview.data.summary)) {
if (typeof value === "number") numericSummary[key] = value;
}
}
const namedKeys = new Set(
RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys),
);
const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({
labelKey: section.labelKey,
count: section.keys.reduce(
(sum, key) => sum + (numericSummary[key] ?? 0),
0,
),
})).filter((entry) => entry.count > 0);
const otherCount = Object.entries(numericSummary)
.filter(([key]) => !namedKeys.has(key))
.reduce((sum, [, value]) => sum + value, 0);
return (
<div className="rounded-md border p-3 text-sm" data-slot="restore-preview">
<p className="text-muted-foreground mb-1.5 font-medium">
{t("admin.section.backups.previewTitle")}
</p>
{preview.isLoading && (
<p className="text-muted-foreground">
{t("admin.section.backups.previewLoading")}
</p>
)}
{preview.isError &&
(previewFailure === "keyMissing" &&
preview.error instanceof ApiError ? (
// The one preview failure the restore would repeat: say which
// key, so the operator can put it back before trying.
<p role="alert" className="text-destructive">
{t("admin.section.backups.previewKeyMissing", {
keys: Array.isArray(preview.error.meta?.keyIds)
? preview.error.meta.keyIds.map(String).join(", ")
: "",
})}
</p>
) : previewFailure === "stillReading" ? (
<p className="text-muted-foreground">
{t("admin.section.backups.previewStillReading")}
</p>
) : (
<p className="text-muted-foreground">
{t("admin.section.backups.previewUnavailable")}
</p>
))}
{preview.isSuccess && (
<ul className="grid list-none grid-cols-1 gap-x-4 gap-y-0.5 p-0 sm:grid-cols-2">
{previewRows.map((entry) => (
<li
key={entry.labelKey}
className="flex items-baseline justify-between gap-2"
>
<span className="text-muted-foreground">
{t(`admin.section.backups.${entry.labelKey}`)}
</span>
<span className="tabular-nums">{fmt.integer(entry.count)}</span>
</li>
))}
{otherCount > 0 && (
<li className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">
{t("admin.section.backups.previewOther")}
</span>
<span className="tabular-nums">{fmt.integer(otherCount)}</span>
</li>
)}
{previewRows.length === 0 && otherCount === 0 && (
<li className="text-muted-foreground">
{t("admin.section.backups.previewEmpty")}
</li>
)}
</ul>
)}
</div>
);
}
/**
* Typed-confirmation dialog for restore. The destructive Restore button
* is gated behind:
* 1. Opening the dialog (one click).
* 2. Reading the warning copy (Title + Description spell out exactly
* what's about to happen + which user it affects).
* 3. Typing the literal string `RESTORE` into the prompt input —
* anything else keeps the confirm button disabled.
*
* Three independent steps before the request fires == "triple confirm".
* Mirrors the wipe dialog's pattern but adds the typed gate because the
* blast radius is bigger (re-creates rows, not just deletes).
*/
export function RestoreRowDialog({
row,
pending,
onConfirm,
}: {
row: BackupRow;
pending: boolean;
onConfirm: (options: { restoreInstanceSettings: boolean }) => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const matched = typed.trim() === "RESTORE";
// Off unless the operator says otherwise. A disaster-recovery snapshot
// carries the host's own settings alongside the account's data, and putting
// one account back is not a reason to reconfigure the installation for
// everybody on it — so the second effect is a second decision.
const [withInstanceSettings, setWithInstanceSettings] = useState(false);
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setTyped("");
setWithInstanceSettings(false);
}
}}
>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
data-testid="backup-restore-trigger"
disabled={pending}
aria-label={t("admin.section.backups.restoreAria", {
username: row.username,
})}
className="min-h-11"
>
{pending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<History className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.restore")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.backups.restoreTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.backups.restoreDescription", {
username: row.username,
when: fmt.dateTime(row.createdAt),
})}
</AlertDialogDescription>
</AlertDialogHeader>
{/* What the file contains, before the typed gate. */}
<RestorePreview backupId={row.id} open={open} />
{/* The second decision, before the typed gate rather than after it:
the account's data comes back either way, the host's settings only
if this is ticked. */}
<label
className="flex items-start gap-2 text-sm"
data-slot="restore-instance-settings"
>
<Checkbox
data-testid="backup-restore-instance-settings"
checked={withInstanceSettings}
onCheckedChange={(next) => setWithInstanceSettings(next === true)}
className="mt-0.5"
/>
<span>
{t("admin.section.backups.restoreInstanceSettingsLabel")}
<span className="text-muted-foreground block text-xs">
{t("admin.section.backups.restoreInstanceSettingsHint")}
</span>
</span>
</label>
<div className="space-y-2">
<Label htmlFor={`restore-prompt-${row.id}`}>
{t("admin.section.backups.restorePromptLabel")}
</Label>
<Input
id={`restore-prompt-${row.id}`}
data-testid="backup-restore-prompt"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="RESTORE"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
data-testid="backup-restore-confirm"
disabled={!matched || pending}
variant="destructive"
onClick={() => {
if (!matched) return;
setOpen(false);
setTyped("");
onConfirm({ restoreInstanceSettings: withInstanceSettings });
setWithInstanceSettings(false);
}}
>
{pending
? t("admin.section.backups.restoreInProgress")
: t("admin.section.backups.restoreConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,353 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status";
import type {
MissingBackupSection,
RestoreSkipSummary,
SkippedCatalogue,
} from "@/lib/export/restore-skips";
/**
* Which catalogue a skipped key belonged to, as a label.
*
* Spelled out with literal `t()` calls rather than an interpolated key, so
* `i18n-call-site-coverage.test.ts` can see each one and prove the bundle has
* it. An interpolated key is invisible to that guard and shows up as raw dot
* notation in production the day someone renames a catalogue.
*/
function catalogueLabel(
catalogue: SkippedCatalogue,
t: ReturnType<typeof useTranslations>["t"],
) {
if (catalogue === "cycleSymptom") {
return t("admin.section.backups.restoreSkippedCycleSymptom");
}
if (catalogue === "illnessSymptom") {
return t("admin.section.backups.restoreSkippedIllnessSymptom");
}
if (catalogue === "moodTag") {
return t("admin.section.backups.restoreSkippedMoodTag");
}
if (catalogue === "visitReference") {
return t("admin.section.backups.restoreSkippedVisitReference");
}
if (catalogue === "vaccinationReference") {
return t("admin.section.backups.restoreSkippedVaccinationReference");
}
if (catalogue === "checkupClosure") {
return t("admin.section.backups.restoreSkippedCheckupClosure");
}
if (catalogue === "reminderReference") {
return t("admin.section.backups.restoreSkippedReminderReference");
}
if (catalogue === "coachAttachment") {
return t("admin.section.backups.restoreSkippedCoachAttachment");
}
if (catalogue === "coachReference") {
return t("admin.section.backups.restoreSkippedCoachReference");
}
if (catalogue === "documentConditionLink") {
return t("admin.section.backups.restoreSkippedDocumentConditionLink");
}
if (catalogue === "relationLink") {
return t("admin.section.backups.restoreSkippedRelationLink");
}
if (catalogue === "extractedFact") {
return t("admin.section.backups.restoreSkippedExtractedFact");
}
if (catalogue === "factCommitment") {
return t("admin.section.backups.restoreSkippedFactCommitment");
}
if (catalogue === "moodFactor") {
return t("admin.section.backups.restoreSkippedMoodFactor");
}
if (catalogue === "personalRecordReference") {
return t("admin.section.backups.restoreSkippedPersonalRecordReference");
}
if (catalogue === "ecgReference") {
return t("admin.section.backups.restoreSkippedEcgReference");
}
if (catalogue === "symptomEpisodeReference") {
return t("admin.section.backups.restoreSkippedSymptomEpisodeReference");
}
if (catalogue === "visitCiphertext") {
return t("admin.section.backups.restoreSkippedVisitCiphertext");
}
if (catalogue === "vaccinationCiphertext") {
return t("admin.section.backups.restoreSkippedVaccinationCiphertext");
}
if (catalogue === "environmentLocationCiphertext") {
return t(
"admin.section.backups.restoreSkippedEnvironmentLocationCiphertext",
);
}
if (catalogue === "moodLabelCiphertext") {
return t("admin.section.backups.restoreSkippedMoodLabelCiphertext");
}
if (catalogue === "cycleCiphertext") {
return t("admin.section.backups.restoreSkippedCycleCiphertext");
}
if (catalogue === "medicationTarget") {
return t("admin.section.backups.restoreSkippedMedicationTarget");
}
if (catalogue === "scheduleRevisionLink") {
return t("admin.section.backups.restoreSkippedScheduleRevisionLink");
}
if (catalogue === "accountSetting") {
return t("admin.section.backups.restoreSkippedAccountSetting");
}
// Not a fallback: the chain above is exhaustive and this line is what makes
// the compiler say so. A catalogue added without a label here now stops the
// build instead of shipping under a label that belongs to something else,
// which is what three of them were already doing — a dropped Coach
// attachment reached the operator's screen labelled "mood factor".
const unlabelled: never = catalogue;
return unlabelled;
}
/**
* Which section a refused file claimed and did not carry, as a label.
*
* Spelled out with literal `t()` calls for the same reason as
* `catalogueLabel` above — an interpolated key is invisible to
* `i18n-call-site-coverage.test.ts`, and a renamed section would ship as raw
* dot notation. The `never` at the foot is what makes the compiler say the
* chain is exhaustive.
*/
function missingSectionLabel(
section: MissingBackupSection,
t: ReturnType<typeof useTranslations>["t"],
) {
if (section === "documents") {
return t("admin.section.backups.restoreMissingDocuments");
}
if (section === "workouts") {
return t("admin.section.backups.restoreMissingWorkouts");
}
if (section === "mentalHealth") {
return t("admin.section.backups.restoreMissingMentalHealth");
}
if (section === "consent") {
return t("admin.section.backups.restoreMissingConsent");
}
const unlabelled: never = section;
return unlabelled;
}
/**
* The refused sections a failed restore job names, or none.
*
* Keyed on the failure's `code` and not on its message: the message is prose
* the server may reword, and matching on it would turn a copy edit into a
* silent loss of the panel.
*/
export function missingSectionsOf(
failure: { code: string; sections?: readonly unknown[] } | null | undefined,
): MissingBackupSection[] {
if (failure?.code !== "backup.section.missing") return [];
const sections = failure.sections;
if (!Array.isArray(sections)) return [];
return sections.filter(
(section): section is MissingBackupSection => typeof section === "string",
);
}
/**
* Why a file was refused, and what to do about it.
*
* Held on screen rather than left to the toast, exactly like the skip report
* below it. The operator refused here is often holding the only copy of an
* account; "restore failed" for four seconds is not enough to act on, and the
* two facts that let them act — which section the file claimed and did not
* carry, and that nothing was changed — have to survive long enough to be read.
*/
export function RestoreRefusalNotice({
sections,
onDismiss,
}: {
sections: MissingBackupSection[];
onDismiss: () => void;
}) {
const { t } = useTranslations();
if (sections.length === 0) return null;
return (
<div
role="alert"
data-slot="restore-refusal-notice"
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.restoreMissingTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.restoreMissingDescription")}
</p>
<ul className="mt-2 space-y-1">
{sections.map((section) => (
<li key={section} className="text-xs">
{missingSectionLabel(section, t)}
</li>
))}
</ul>
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.section.backups.restoreMissingRemedy")}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="min-h-11"
>
{t("common.dismiss")}
</Button>
</div>
</div>
);
}
/**
* What the last restore could not put back.
*
* Its own component, and exported, so it can be rendered against a report in a
* test instead of only through a state transition the SSR smoke tests here
* cannot drive. A report that reaches the response and never reaches the screen
* is the same silence the reporting exists to end, so the render is worth
* proving on its own.
*
* It names every key rather than totalling them away: an operator who cannot
* see WHICH symptom went missing cannot decide whether it mattered.
*/
export function RestoreSkipReport({
report,
onDismiss,
}: {
report: RestoreSkipSummary;
onDismiss: () => void;
}) {
const { t } = useTranslations();
if (report.links === 0) return null;
return (
<div
role="status"
data-slot="restore-skip-report"
className="border-warning/40 bg-warning/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.restoreSkippedTitle", {
links: String(report.links),
})}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.restoreSkippedDescription")}
</p>
<ul className="mt-2 space-y-1">
{report.catalogueKeys.map((entry) => (
<li key={`${entry.catalogue}:${entry.key}`} className="text-xs">
<span className="font-mono">{entry.key}</span>{" "}
<span className="text-muted-foreground">
{catalogueLabel(entry.catalogue, t)}
{" · "}
{t("admin.section.backups.restoreSkippedLinks", {
count: String(entry.links),
})}
</span>
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="min-h-11"
>
{t("common.dismiss")}
</Button>
</div>
</div>
);
}
/**
* Says out loud whether the SCHEDULE is still alive.
*
* The weekly pass on the maintainer's own instance stopped producing copies
* and nothing surfaced it for a month and a half: the table listed the rows it
* had, each with an ordinary timestamp, and a copy from six weeks ago reads
* exactly like one made on Sunday until somebody does the subtraction. Two
* independent facts are shown, because they fail at different times — the age
* of the newest scheduled copy never ages out, while the run's own outcome
* carries the reason ("job timed out") and survives only as long as pg-boss
* keeps the row.
*
* Not dismissible: it reflects live state, so the only way to clear it is to
* make a backup succeed.
*/
export function ScheduleHealthNotice({
schedule,
}: {
schedule: BackupScheduleStatus;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!schedule.stale && !schedule.lastRunFailed) return null;
return (
<div
role="alert"
data-slot="backup-schedule-notice"
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<div>
<p className="font-medium">
{schedule.stale
? t("admin.section.backups.scheduleStaleTitle")
: t("admin.section.backups.scheduleFailedTitle")}
</p>
{schedule.stale ? (
<p className="text-xs">
{t("admin.section.backups.scheduleStaleDescription", {
days: schedule.lastSuccessAgeDays ?? 0,
threshold: schedule.staleAfterDays,
})}
</p>
) : null}
{schedule.lastRunFailed && schedule.lastRun ? (
<p className="text-xs">
{t("admin.section.backups.scheduleFailedDescription", {
when: fmt.dateTime(schedule.lastRun.at),
reason:
schedule.lastRun.error ||
t("admin.section.backups.scheduleFailedNoReason"),
})}
</p>
) : null}
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.section.backups.scheduleRemedy")}
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,100 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import {
ReproofCancelledError,
throwIfReproofRequired,
} from "@/components/settings/security-section/use-recent-proof";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow } from "@/types/backups";
import { getApiErrorMessage } from "../_shared";
/** "Run backup now": enqueues the pg-boss `data-backup` job. */
export function useRunBackup() {
const { t } = useTranslations();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
return apiPost<{ jobId: string | null }>("/api/admin/backups/run");
},
onSuccess: () => {
toast.success(t("admin.section.backups.runEnqueued"));
// The job runs async; refetch after a short delay so the new row
// shows up without leaving the user wondering whether the click
// did anything.
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
}, 2000);
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.runFailed"),
{ cause: err },
);
},
});
}
/** One backup file to the browser, after a fresh proof when one is due. */
export function useBackupDownload(
recentProof: ReturnType<typeof useRecentProof>,
) {
const { t } = useTranslations();
// Per-row download in-flight state — keyed by backup id so two
// parallel clicks on different rows don't share a single spinner.
const [downloadingId, setDownloadingId] = useState<string | null>(null);
async function handleDownload(row: BackupRow) {
setDownloadingId(row.id);
try {
// apiFetchRaw: the download needs the raw Response for the blob +
// the Content-Disposition filename header.
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
),
);
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
// Use the server-provided Content-Disposition filename if present,
// otherwise fall back to a deterministic client-side name. The
// server filename is more accurate (uses the actual createdAt).
const cd = res.headers.get("content-disposition") ?? "";
const match = cd.match(/filename="?([^";]+)"?/i);
const fallback = `healthlog-backup-${row.userId}-${row.createdAt.slice(0, 10)}.json`;
const filename = match?.[1] ?? fallback;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("admin.section.backups.downloadStarted"));
} catch (err) {
if (err instanceof ReproofCancelledError) return;
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
{ cause: err },
);
} finally {
setDownloadingId(null);
}
}
return { downloadingId, handleDownload };
}
@@ -0,0 +1,213 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import type {
MissingBackupSection,
RestoreSkipSummary,
} from "@/lib/export/restore-skips";
import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { randomId } from "@/lib/random-id";
import type { BackupRow } from "@/types/backups";
import { isActiveRestore } from "../backup-restore-status";
import { missingSectionsOf } from "./restore-notices";
const DISMISSED_RESTORES_KEY = "healthlog.admin.backups.dismissedRestores";
/** Dismissed restore panels, from this browser. Empty when storage is off. */
function readDismissedRestores(): string[] {
try {
if (typeof window === "undefined") return [];
const raw = window.localStorage.getItem(DISMISSED_RESTORES_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string")
: [];
} catch {
return [];
}
}
function writeDismissedRestores(ids: string[]): void {
try {
// The list only ever shows a day of jobs; fifty ids is weeks of them.
window.localStorage.setItem(
DISMISSED_RESTORES_KEY,
JSON.stringify(ids.slice(-50)),
);
} catch {
// A private window: the panel comes back on reload, nothing else breaks.
}
}
/**
* The restores: the server's job list (polled while one runs), what this page
* saw finish, the panels the operator dismissed, the refusal and skip reports
* held on screen, and the typed-confirmation request that queues a restore.
*/
export function useBackupRestores(
recentProof: ReturnType<typeof useRecentProof>,
) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// What the last restore could not put back. Held in state rather than left
// to the toast: a toast for "twelve symptom links are gone" disappears in
// four seconds and the operator has no way to get it back. This panel stays
// until they dismiss it.
const [skipped, setSkipped] = useState<RestoreSkipSummary | null>(null);
// Why the last restore was refused before it touched anything. Same reason
// the skip report is held rather than toasted: the operator has to be able to
// read it after the four seconds are up.
const [refusedSections, setRefusedSections] = useState<
MissingBackupSection[]
>([]);
// A job this page watched while it ran, and has now seen finish. Only those
// raise a toast and refresh the page's data: a job that had already finished
// when the page opened is shown in its panel and nothing more. Read in the
// poll's own callback, where each answer arrives, rather than in an effect.
const watched = useRef<Map<string, string>>(new Map());
function noticeFinishedRestores(jobs: readonly BackupRestoreJobView[]) {
for (const job of jobs) {
const before = watched.current.get(job.id);
watched.current.set(job.id, job.status);
if (before !== "queued" && before !== "running") continue;
if (job.status === "succeeded") {
setRefusedSections([]);
const report = job.result?.skipped;
if (report && report.links > 0) {
setSkipped(report as RestoreSkipSummary);
toast.warning(
t("admin.section.backups.restoreSkippedToast", {
links: String(report.links),
}),
);
} else {
setSkipped(null);
toast.success(t("admin.section.backups.restoreSuccess"));
}
// Restore touches every personal-data table; nuke the broader cache
// so dashboards / lists rebuild against the new state.
void queryClient.invalidateQueries();
} else if (job.status === "failed") {
setRefusedSections(missingSectionsOf(job.failure));
showErrorToast(t("admin.section.backups.restoreFailed"));
}
}
}
// The restore jobs, read from the server so a reload during a restore finds
// it again. Polled every two seconds while any is queued or running, and
// not at all otherwise. A poll that fails keeps the last answer on screen:
// a network blip is not a failed restore.
const restores = useQuery({
queryKey: queryKeys.adminBackupRestores(),
queryFn: async () => {
const data = await apiGet<{ jobs: BackupRestoreJobView[] }>(
"/api/admin/backups/restores",
);
noticeFinishedRestores(data.jobs);
return data;
},
refetchInterval: (query) =>
query.state.data?.jobs.some(isActiveRestore) ? 2000 : false,
});
const restoreJobs = restores.data?.jobs ?? [];
const activeOwners = new Set(
restoreJobs.filter(isActiveRestore).map((job) => job.userId),
);
// Finished restores the operator dismissed. Remembered in this browser only,
// for convenience; the list itself forgets a job after a day.
const [dismissedRestores, setDismissedRestores] = useState<Set<string>>(
() => new Set(readDismissedRestores()),
);
function dismissRestore(id: string) {
setDismissedRestores((previous) => {
const next = new Set(previous);
next.add(id);
writeDismissedRestores([...next]);
return next;
});
}
// Restore: typed-confirmation dialog. The request only queues the restore;
// the panel above the list follows it from there.
const restore = useMutation({
mutationFn: async ({
row,
restoreInstanceSettings,
}: {
row: BackupRow;
restoreInstanceSettings: boolean;
}) => {
// Idempotency-Key prevents a double-click from queuing a second
// restore. Include the row id so two different backups can both be
// restored independently in the same minute.
const idempotencyKey = `restore-${row.id}-${randomId()}`;
return recentProof.run(() =>
apiPost<{ jobId: string; status: "queued" }>(
`/api/admin/backups/${row.id}/restore`,
{ confirm: "RESTORE", restoreInstanceSettings },
{ headers: { "Idempotency-Key": idempotencyKey } },
),
);
},
onSuccess: (data) => {
setRefusedSections([]);
setSkipped(null);
watched.current.set(data.jobId, "queued");
// Neutral, not a success: nothing has been restored yet.
toast.info(t("admin.section.backups.restoreStarted"));
void queryClient.invalidateQueries({
queryKey: queryKeys.adminBackupRestores(),
});
},
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
if (
err instanceof ApiError &&
err.meta?.errorCode === "backup.restore.active"
) {
showErrorToast(t("admin.section.backups.restoreActive"), {
cause: err,
});
void queryClient.invalidateQueries({
queryKey: queryKeys.adminBackupRestores(),
});
return;
}
setRefusedSections([]);
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
{ cause: err },
);
},
});
return {
restoreJobs,
dismissedRestores,
dismissRestore,
restore,
/** Whether a row's restore control waits: its request or its owner's job. */
restorePending: (row: BackupRow) =>
(restore.isPending && restore.variables?.row.id === row.id) ||
activeOwners.has(row.userId),
skipped,
setSkipped,
refusedSections,
setRefusedSections,
};
}
@@ -0,0 +1,156 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef, type ChangeEvent } from "react";
import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { apiFetch, apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* The upload body for a backup file: the file itself when it is already
* gzip, otherwise the file compressed in the browser. The server accepts
* both; compressing keeps a large export well inside the request size limit.
*/
async function uploadBody(file: File): Promise<Blob> {
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
if (head[0] === 0x1f && head[1] === 0x8b) return file;
return new Response(
file.stream().pipeThrough(new CompressionStream("gzip")),
).blob();
}
/**
* Send a backup file to the admin upload route. Sent as the raw body,
* compressed on the way unless it already is: the server reads it as a stream,
* and a large export (hundreds of MB of JSON) shrinks about tenfold. No request
* timeout, because a file of that size takes longer to check and store than
* the default allows. The body is built here, per call, so a retry after a
* re-proof sends the file again rather than a spent stream.
*/
async function uploadOnce(file: File) {
const body = await uploadBody(file);
return apiFetch<{
id: string;
valid: true;
summary: {
measurements: number;
medications: number;
intakeEvents: number;
moodEntries: number;
// Optional for the same reason as the keys below: a file written
// before side effects rode the wire carries no key at all.
medicationSideEffects?: number;
cycles?: number;
cycleDayLogs?: number;
// Backup-completeness — optional so an older-schema upload
// (pre-v1.28) still totals correctly without these keys.
labResults?: number;
biomarkers?: number;
illnessEpisodes?: number;
illnessDayLogs?: number;
allergies?: number;
familyHistory?: number;
workouts?: number;
documents?: number;
// Optional for the same reason: a file written before each of these
// rode the wire carries no key, and an absent key must total as
// nothing rather than break the count.
nutrientDays?: number;
healthProfile?: number;
healthProfileFactRevisions?: number;
customMetrics?: number;
customMetricEntries?: number;
intradayProfiles?: number;
};
}>("/api/admin/backups/upload", {
method: "POST",
body,
headers: { "Content-Type": "application/gzip" },
signal: null,
});
}
export type UploadSummary = Awaited<ReturnType<typeof uploadOnce>>["summary"];
/** How many records an accepted upload carries, every section counted. */
export function uploadedRecordCount(summary: UploadSummary): number {
return (
summary.measurements +
summary.medications +
summary.intakeEvents +
summary.moodEntries +
(summary.medicationSideEffects ?? 0) +
(summary.cycles ?? 0) +
(summary.cycleDayLogs ?? 0) +
(summary.labResults ?? 0) +
(summary.biomarkers ?? 0) +
(summary.illnessEpisodes ?? 0) +
(summary.illnessDayLogs ?? 0) +
(summary.allergies ?? 0) +
(summary.familyHistory ?? 0) +
(summary.workouts ?? 0) +
(summary.documents ?? 0) +
// The count the admin is shown has to be the count the file carries.
// The nutrient day totals were added to the payload and never to this
// sum, so an upload that restored them reported fewer records than it
// held — a smaller version of the same defect, on the reporting side.
(summary.nutrientDays ?? 0) +
(summary.healthProfile ?? 0) +
(summary.healthProfileFactRevisions ?? 0) +
(summary.customMetrics ?? 0) +
(summary.customMetricEntries ?? 0) +
(summary.intradayProfiles ?? 0)
);
}
/**
* The single-file upload flow: a fresh proof first, then the file, and the
* file input reset after either outcome.
*/
export function useBackupUpload(
recentProof: ReturnType<typeof useRecentProof>,
) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// Upload state — single-file flow, drives the file input + button.
const fileInputRef = useRef<HTMLInputElement | null>(null);
const upload = useMutation({
mutationFn: async (file: File) => {
// Ask first, so a large file is not sent once only to be refused.
await recentProof.run(() => apiGet("/api/auth/reproof"));
return uploadOnce(file);
},
onSuccess: (data) => {
const total = uploadedRecordCount(data.summary);
toast.success(
t("admin.section.backups.uploadSuccess", { count: String(total) }),
);
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
},
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.uploadFailed"),
{ cause: err },
);
},
onSettled: () => {
// Reset the file input so the same file can be re-selected after a
// rejected upload (browsers keep the previous selection otherwise).
if (fileInputRef.current) fileInputRef.current.value = "";
},
});
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) upload.mutate(file);
}
return { upload, fileInputRef, handleFileChange };
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { resolveAnnotationPositions } from "../health-chart";
import { resolveAnnotationPositions } from "../health-chart/series-math";
/**
* v1.4.20 — storyboard-annotation pinning.
@@ -1,3 +1,4 @@
import { baselineLayers } from "../health-chart/chart-layers";
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -54,28 +55,22 @@ vi.mock("@/hooks/use-auth", () => ({
}));
describe("HealthChart default overlay state", () => {
it("source: personal baseline is gated behind a toggle (no unconditional ReferenceLine)", () => {
// The B1a wrapper rendered the baseline `<ReferenceLine>` for every
// type unconditionally. Make sure the source no longer paints it
// outside an opt-in branch — we look for the tell-tale pattern of
// `types.map((type, i) => { ... <ReferenceLine ... y={baseline}`
// appearing without a `showTrend` / `showBaseline` guard above it.
const moduleUrl = new URL("../health-chart.tsx", import.meta.url);
const src = readFileSync(fileURLToPath(moduleUrl), "utf8");
// Personal-baseline rendering must be guarded — the JSX block that
// emits the dashed baseline must be wrapped in a conditional
// expression starting with a state flag.
const baselineSection = src.match(
/\{types\.map\(\(type, i\) => \{[\s\S]*?const baseline = personalBaselines\.get\(type\);[\s\S]*?\}\)\}/,
it("paints the personal baseline only behind the trend toggle", () => {
// An always-on dashed mean line was rejected: the baseline layer paints
// nothing with the trend overlay off, and one line per type with it on.
const baselines = new Map([["WEIGHT", 80]]);
const t = (key: string) => key;
expect(
baselineLayers(false, ["WEIGHT"], ["var(--chart-1)"], baselines, t),
).toBe(false);
const on = baselineLayers(
true,
["WEIGHT"],
["var(--chart-1)"],
baselines,
t,
);
if (baselineSection) {
// If the section still exists, it's not gated. Allow a guard
// wrapper test instead — match `{showSomething &&`.
throw new Error(
"Personal baseline ReferenceLine is rendered unconditionally — must be gated behind an opt-in toggle.",
);
}
expect(Array.isArray(on) && on.filter(Boolean)).toHaveLength(1);
});
it("does NOT paint the personal-baseline label by default in MoodChart source", () => {
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { computePersonalBaseline } from "../health-chart";
import { computePersonalBaseline } from "../health-chart/series-math";
/**
* v1.4.16 — HealthChart Apple-Health-style polish contract.
@@ -11,7 +11,8 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const HEALTH_CHART_PATH = resolve(__dirname, "..", "health-chart.tsx");
import { readSource, readSourceFolder } from "@/__tests__/helpers/source-files";
const WEIGHT_PAGE_PATH = resolve(
__dirname,
"../../../app/insights/weight/page.tsx",
@@ -24,7 +25,13 @@ const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json");
describe("v1.16.8 — chart-card target-adjust link removed", () => {
it("ships no targetSettingsHref prop or header link in the chart", () => {
const source = readFileSync(HEALTH_CHART_PATH, "utf8");
// The component and every part it is built from (header included).
const source = [
readSource("src/components/charts/health-chart.tsx", "HealthChart"),
...readSourceFolder("src/components/charts/health-chart", {
floor: 9,
}).map(([, text]) => text),
].join("\n");
expect(source).not.toContain("targetSettingsHref");
expect(source).not.toContain("chart-target-settings-link");
expect(source).not.toContain("charts.adjustTargets");
@@ -113,7 +113,7 @@ describe("measurementLabelKey", () => {
it("matches the canonical label map for every type outside the chart's own labels", async () => {
const { MEASUREMENT_TYPE_LABEL_KEYS } =
await import("@/lib/measurements/type-label-keys");
const { measurementLabelKey } = await import("../health-chart");
const { measurementLabelKey } = await import("../health-chart/series-math");
// The four types whose key breaks the convention carry a chart label.
const chartLabelled = new Set([
"WEIGHT",
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { resolveVerticalMarkerPositions } from "../health-chart";
import { resolveVerticalMarkerPositions } from "../health-chart/series-math";
/**
* v1.4.25 — GLP-1 injection-day vertical-marker pinning.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,43 @@
import type { VisibleValueBand } from "./types";
/**
* The value bands, painted as HTML behind the plot. The overlay is pinned to
* the plot's own edges (the chart margin of 8 px plus the y axis on the left,
* the 8 px margin on the right, the plot's top and bottom insets), so a band
* tracks the line exactly.
*/
export function ChartBandOverlay({
bands,
yAxisWidth,
}: {
bands: VisibleValueBand[];
yAxisWidth: number;
}) {
return (
<div
className="pointer-events-none absolute"
style={{
left: `${8 + yAxisWidth}px`,
right: "8px",
top: "10px",
bottom: "32px",
zIndex: 0,
}}
>
{bands.map((band) => (
<div
key={band.key}
className="absolute right-0 left-0"
style={{
top: `${band.topPct}%`,
height: `${band.heightPct}%`,
backgroundColor: band.color,
opacity: band.opacity,
borderTop: `1px solid ${band.color}`,
borderBottom: `1px solid ${band.color}`,
}}
/>
))}
</div>
);
}
@@ -0,0 +1,114 @@
"use client";
import type { ComponentType } from "react";
import { TileHeader } from "@/components/insights/tile-header";
import type { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import type {
ChartRangePoints,
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import { useTranslations } from "@/lib/i18n/context";
import { ChartOverlayControls } from "../chart-overlay-controls";
import { ChartRangeTabs } from "../chart-range-tabs";
/**
* The chart card's header. Mobile first: the title and its chips on one row,
* the range tabs and the overlay cog right-aligned on a second; the bucket
* and comparison chips hide below `sm` (the range tabs already say which
* window is shown, and the cog still reaches the overlays). From `sm` up
* everything sits on one row.
*/
export function HealthChartHeader({
title,
titleIcon,
bucketCaption,
compareBaseline,
hasComparisonData,
rangePoints,
onRangeChange,
overlayPrefs,
showOverlayControls,
}: {
title: string;
titleIcon?: ComponentType<{ className?: string }>;
bucketCaption: string | null;
compareBaseline: ComparisonBaseline;
hasComparisonData: boolean;
rangePoints: number;
onRangeChange: (days: ChartRangePoints) => void;
overlayPrefs: ReturnType<typeof useChartOverlayPrefs>;
showOverlayControls: boolean;
}) {
const { t } = useTranslations();
return (
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
{/* With a `titleIcon` (every metric page) the card leads with the
canonical `<TileHeader>`; the dashboard and ad-hoc mounts keep the
compact heading. That heading is an `<h2>`: the card is a sibling
of the other cards in its grid, which name themselves at the
second level, and one level lower skipped a level under the page
`<h1>`. */}
{titleIcon ? (
<TileHeader icon={titleIcon} title={title} />
) : (
<h2 className="text-sm font-semibold">{title}</h2>
)}
{bucketCaption && (
<span
data-slot="chart-bucket-caption"
className="bg-muted/40 text-muted-foreground text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
>
{t(bucketCaption)}
</span>
)}
{/* The comparison caption sits beside the bucket chip: "which window"
and "which overlay" in one glance. The unavailable fallback is
muted so it does not read as an error. */}
{compareBaseline !== "none" && hasComparisonData && (
<span
className="text-dose-accent bg-dose-accent/10 text-2xs hidden rounded-md border border-current/30 px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{compareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
compareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
{/* The page-level target control owns "adjust targets"; the chart
card stays a read surface. */}
</div>
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
<ChartRangeTabs value={rangePoints} onChange={onRangeChange} />
{/* Only a chart bound to a persistent key offers the overlay
controls; ad-hoc charts keep the clean line. */}
{showOverlayControls ? (
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={hasComparisonData}
/>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,352 @@
"use client";
/**
* The reference layers and series inside `HealthChart`'s plot.
*
* Each is a plain function that returns the Recharts elements (never a
* component wrapping them), so the plot's child list is exactly the list of
* elements it always was: Recharts lays its layers out in source order, and a
* wrapper component would change what it sees.
*/
import {
Area,
Line,
ReferenceArea,
ReferenceDot,
ReferenceLine,
} from "recharts";
import { OPEN_DAY_LINE } from "@/components/day/chart-day-links";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import type { ResolvedAnnotation, ResolvedVerticalMarker } from "./series-math";
import type { ChartDataPoint, VisibleTargetZone } from "./types";
type Translate = (key: string) => string;
/** Target zones: the shaded area, then the dashed low and high edges. */
export function targetZoneLayers(
zones: VisibleTargetZone[],
maxPointIndex: number,
) {
return [
zones.map((zone) => (
<ReferenceArea
key={`${zone.key}-fill`}
x1={0}
x2={maxPointIndex}
y1={zone.low}
y2={zone.high}
fill={zone.color}
fillOpacity={zone.opacity}
strokeOpacity={0}
ifOverflow="discard"
/>
)),
zones.map((zone) => (
<ReferenceLine
key={`${zone.key}-low`}
y={zone.low}
stroke={zone.color}
strokeDasharray="6 4"
strokeWidth={1.75}
strokeOpacity={zone.lineOpacity}
/>
)),
zones.map((zone) => (
<ReferenceLine
key={`${zone.key}-high`}
y={zone.high}
stroke={zone.color}
strokeDasharray="6 4"
strokeWidth={1.75}
strokeOpacity={zone.lineOpacity}
label={
zone.label
? {
value: zone.label,
position: "right",
fill: zone.textColor ?? zone.color,
fontSize: 10,
}
: undefined
}
/>
)),
];
}
/**
* Storyboard annotations: a vertical line on the visible point with its label
* above, truncated to 24 characters below 640 px.
*/
export function annotationLayers(
annotations: ResolvedAnnotation[],
viewportWidth: number,
) {
return annotations.map((annotation, i) => (
<ReferenceLine
key={`storyboard-${i}-${annotation.pointIndex}`}
x={annotation.pointIndex}
stroke={annotation.color}
strokeDasharray="4 4"
strokeWidth={1.25}
strokeOpacity={0.7}
ifOverflow="discard"
label={{
value:
viewportWidth < 640 ? annotation.truncatedLabel : annotation.label,
position: "insideTopRight",
fill: annotation.color,
fontSize: 10,
fontWeight: 500,
}}
/>
));
}
/**
* Event markers: a thin dashed line and a dot where it meets the x axis, so a
* row of them reads as a cadence. A label only when the caller gave one.
*/
export function verticalMarkerLayers(
markers: ResolvedVerticalMarker[],
yDomain: [number, number] | undefined,
) {
return [
markers.map((marker, i) => (
<ReferenceLine
key={`vmarker-${i}-${marker.pointIndex}`}
x={marker.pointIndex}
stroke={marker.color}
strokeDasharray="3 3"
strokeWidth={1.1}
strokeOpacity={0.55}
ifOverflow="discard"
label={
marker.label
? {
value: marker.label,
position: "insideTopRight",
fill: marker.color,
fontSize: 10,
fontWeight: 500,
}
: undefined
}
/>
)),
yDomain &&
markers.map((marker, i) => (
<ReferenceDot
key={`vmarker-dot-${i}-${marker.pointIndex}`}
x={marker.pointIndex}
y={yDomain[0]}
r={3}
fill={marker.color}
stroke="none"
ifOverflow="discard"
/>
)),
];
}
/**
* The open day: a dashed line through it and a ring on each of its values,
* so the point the day panel describes stays in sight beside it.
*/
export function openDayLayers(
openDayIndex: number | undefined,
openDayPoint: ChartDataPoint | undefined,
types: string[],
colors: string[],
) {
return [
openDayIndex !== undefined ? (
<ReferenceLine key="open-day" x={openDayIndex} {...OPEN_DAY_LINE} />
) : null,
openDayIndex !== undefined && openDayPoint
? types.map((type, i) => {
const v = openDayPoint[type];
if (typeof v !== "number") return null;
return (
<ReferenceDot
key={`open-day-${type}`}
x={openDayIndex}
y={v}
r={6.5}
fill="var(--card)"
stroke={colors[i % colors.length]}
strokeWidth={2.5}
ifOverflow="discard"
/>
);
})
: null,
];
}
/**
* The personal baseline per type, only while the trend overlay is shown (an
* always-on mean line was rejected). Only the first type carries the label,
* so a multi-type chart does not paint it twice.
*/
export function baselineLayers(
showTrend: boolean,
types: string[],
colors: string[],
personalBaselines: Map<string, number>,
t: Translate,
) {
return (
showTrend &&
types.map((type, i) => {
const baseline = personalBaselines.get(type);
if (baseline == null) return null;
return (
<ReferenceLine
key={`baseline-${type}`}
y={baseline}
stroke={colors[i % colors.length]}
strokeDasharray="2 4"
strokeOpacity={0.4}
strokeWidth={1}
ifOverflow="discard"
label={
i === 0
? {
value: t("charts.personalBaseline"),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
opacity: 0.7,
}
: undefined
}
/>
);
})
);
}
/**
* The series, in paint order: the min–max range band behind the mean line
* (not in mini mode, and only with the target-range toggle, which controls
* both bands), the lines, the dashed 7-day average, the trend line, and the
* dimmed prior period (thinner and at 45 % so the current line reads first).
*/
export function seriesLayers({
types,
colors,
label,
t,
mini,
showBands,
showMA,
showTrend,
compareBaseline,
hasComparisonData,
animationsEnabled,
}: {
types: string[];
colors: string[];
label: (type: string) => string;
t: Translate;
mini: boolean;
showBands: boolean;
showMA: boolean;
showTrend: boolean;
compareBaseline: ComparisonBaseline;
hasComparisonData: boolean;
animationsEnabled: boolean;
}) {
return [
!mini &&
showBands &&
types.map((type, i) => (
<Area
key={`${type}__range`}
type="monotone"
dataKey={`${type}__range`}
name={`${label(type)} (${t("charts.rangeBand")})`}
stroke="none"
fill={colors[i % colors.length]}
fillOpacity={0.12}
connectNulls
isAnimationActive={animationsEnabled}
legendType="none"
tooltipType="none"
/>
)),
types.map((type, i) => (
<Line
key={type}
type="monotone"
dataKey={type}
name={label(type)}
stroke={colors[i % colors.length]}
strokeWidth={2}
dot={{ r: 3, fill: colors[i % colors.length] }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
)),
showMA &&
types.map((type, i) => (
<Line
key={`${type}_ma`}
type="monotone"
dataKey={`${type}_ma`}
name={`${label(type)} (${t("charts.movingAverage7d")})`}
stroke={colors[i % colors.length]}
strokeWidth={1.5}
strokeDasharray="5 5"
strokeOpacity={0.7}
dot={false}
connectNulls
/>
)),
showTrend &&
types.map((type) => (
<Line
key={`${type}_trend`}
type="linear"
dataKey={`${type}_trend`}
name={`${label(type)} (${t("charts.trend")})`}
stroke="var(--muted-foreground)"
strokeWidth={1}
strokeDasharray="8 4"
dot={false}
connectNulls
/>
)),
compareBaseline !== "none" &&
hasComparisonData &&
types.map((type, i) => (
<Line
key={`${type}_compare`}
type="monotone"
dataKey={`${type}_compare`}
name={`${label(type)} (${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)})`}
stroke={colors[i % colors.length]}
strokeWidth={1.25}
strokeDasharray="4 3"
strokeOpacity={0.45}
dot={false}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
legendType="none"
data-slot={`chart-compare-line-${type}`}
/>
)),
];
}
@@ -0,0 +1,158 @@
"use client";
import type { ReactNode } from "react";
import type { useFormatters } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { RichChartTooltip, type RichTooltipRow } from "../chart-tooltip";
import type { ChartDataPoint } from "./types";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
interface TooltipPayloadItem {
name?: string;
value?: number;
color?: string;
dataKey?: string;
payload?: ChartDataPoint;
}
/**
* The tooltip's `content` renderer. One row per series under the cursor,
* with a delta: against the prior period when the comparison is on and the
* day has a prior value (and the prior value as its own row), otherwise
* against the personal baseline. The auxiliary lines (`*_ma`, `*_trend`,
* `*_compare`) are skipped: the comparison shows up as the delta, and the
* average and trend are already drawn as dashed overlays.
*/
export function makeTooltipContent({
chartData,
dayLinkSource,
personalBaselines,
compareBaseline,
bucketCaption,
unit,
t,
fmt,
dateLabelFor,
formatValue,
tooltipAction,
}: {
chartData: ChartDataPoint[] | undefined;
dayLinkSource: ChartDataPoint[] | undefined;
personalBaselines: Map<string, number>;
compareBaseline: ComparisonBaseline;
bucketCaption: string | null;
unit?: string;
t: Translate;
fmt: ReturnType<typeof useFormatters>;
dateLabelFor: (date: Date) => string;
formatValue: (value: number) => string;
tooltipAction: (index: number | undefined) => ReactNode | undefined;
}) {
const unitSuffix = unit ? ` ${unit}` : "";
const signedDelta = (diff: number) => {
const sign = diff > 0 ? "+" : "−";
return `${sign}${fmt.number(Math.abs(diff), 1)}${unitSuffix}`;
};
return function TooltipContent(props: unknown) {
const {
active,
payload,
label: rechartsLabel,
} = props as {
active?: boolean;
payload?: TooltipPayloadItem[];
label?: number;
};
if (!active || !payload?.length) return null;
const ts =
payload[0]?.payload?.timestamp ??
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts ? dateLabelFor(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
// The current and prior values of the hovered day come from the same
// payload object.
const hoverPoint = payload[0]?.payload as ChartDataPoint | undefined;
for (const item of payload) {
if (typeof item.value !== "number") continue;
const dataKey = String(item.dataKey ?? "");
if (
dataKey.endsWith("_ma") ||
dataKey.endsWith("_trend") ||
dataKey.endsWith("_compare")
)
continue;
const baseline = personalBaselines.get(dataKey);
let delta: string | undefined;
const compareValue =
compareBaseline !== "none" && hoverPoint
? (hoverPoint[`${dataKey}_compare`] as number | undefined)
: undefined;
if (
compareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
const diff = item.value - compareValue;
delta =
Math.abs(diff) < 0.05
? t("charts.deltaUnchanged")
: t(
compareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", signedDelta(diff));
} else if (baseline != null) {
const diff = item.value - baseline;
delta =
Math.abs(diff) < 0.05
? t("charts.deltaUnchanged")
: t("charts.deltaVsBaseline").replace("{delta}", signedDelta(diff));
}
rows.push({
// Name the statistic where it is not the plain mean of the readings:
// a day of pulse is the mean of its hours' means.
name: usesHourlyMeanDay(dataKey)
? t("charts.tooltipHourlyMean", {
metric: item.name ?? dataKey,
})
: (item.name ?? dataKey),
value: `${formatValue(item.value)}${unitSuffix}`,
color: item.color ?? "var(--dracula-purple)",
delta,
});
if (
compareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
rows.push({
name: `${item.name ?? dataKey} · ${t("comparison.tooltipPrior")}`,
value: `${formatValue(compareValue)}${unitSuffix}`,
color: item.color ?? "var(--dracula-purple)",
});
}
}
if (rows.length === 0) return null;
return (
<RichChartTooltip
active
label={bucketCaption ? `${dateLabel} · ${t(bucketCaption)}` : dateLabel}
rows={rows}
action={tooltipAction(
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
)}
/>
);
};
}
@@ -0,0 +1,221 @@
/**
* The pure arithmetic behind `HealthChart`: series names, day keys, the
* moving average, the personal baseline and where annotations and markers
* land. No React and no Recharts, so each piece has direct unit coverage.
*/
import { chartPointDayKey } from "@/components/day/chart-day";
import { translateOrNull } from "@/lib/i18n/translate-code";
const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "charts.weight",
BLOOD_PRESSURE_SYS: "charts.systolic",
BLOOD_PRESSURE_DIA: "charts.diastolic",
PULSE: "charts.pulse",
BODY_FAT: "charts.bodyFat",
SLEEP_DURATION: "charts.sleep",
ACTIVITY_STEPS: "charts.steps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
TOTAL_BODY_WATER: "charts.bodyWater",
BONE_MASS: "charts.boneMass",
OXYGEN_SATURATION: "charts.spo2",
};
/**
* The measurement label key for a type outside the chart's short labels:
* `RESTING_HEART_RATE` → `measurements.typeRestingHeartRate`. Derived rather
* than imported, because the full label map would ride into every route that
* draws a chart; `health-chart-type-label.test.tsx` pins that the derivation
* matches `MEASUREMENT_TYPE_LABEL_KEYS` for every type it is used for.
*/
export function measurementLabelKey(type: string): string {
return `measurements.type${type
.toLowerCase()
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("")}`;
}
/**
* The name a series carries in the trend line, the legend, the tooltip and
* the spoken summary. The chart's own short labels come first; every other
* type takes its measurement label, so no overlay ever shows the raw enum.
*/
export function getTypeLabel(
type: string,
valueMode: "raw" | "bmi",
t: (key: string) => string,
): string {
if (type === "WEIGHT" && valueMode === "bmi") {
return "BMI";
}
const key = BASE_TYPE_LABEL_KEYS[type];
if (key) return t(key);
const measurementKey = measurementLabelKey(type);
return translateOrNull(t, measurementKey) ?? type;
}
/**
* A `YYYY-MM-DD` day-key formatter pinned to the time zone. Memoised per zone
* by the caller so the per-row loop reuses one `Intl.DateTimeFormat`.
*/
export function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat {
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
export function toDayKey(
value: string,
formatter: Intl.DateTimeFormat,
): string {
const parts = formatter.formatToParts(new Date(value));
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
if (!year || !month || !day) {
throw new Error("Could not derive day key for chart");
}
return `${year}-${month}-${day}`;
}
export function dayKeyToTimestamp(dayKey: string): number {
const [year, month, day] = dayKey.split("-").map(Number);
return Date.UTC(year, month - 1, day, 12, 0, 0);
}
export function movingAverageByPoints(
data: Array<{ date: Date; value: number }>,
windowSize: number,
): Array<{ date: Date; value: number }> {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
return sorted.map((point, index) => {
const start = Math.max(0, index - windowSize + 1);
const window = sorted.slice(start, index + 1);
const avg =
window.reduce((sum, item) => sum + item.value, 0) / window.length;
return {
date: point.date,
value: Math.round(avg * 100) / 100,
};
});
}
/**
* The personal baseline: the median of a daily series, over at most its most
* recent 90 points (the rolling window "your normal" is framed on). Null
* under 5 points, since a baseline drawn from one or two readings misleads.
*/
export function computePersonalBaseline(
data: Array<{ value: number }>,
windowPoints = 90,
): number | null {
if (data.length < 5) return null;
const recent = data.slice(-windowPoints);
const sorted = recent.map((d) => d.value).sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2;
}
return sorted[mid];
}
/**
* An annotation placed on a visible point, with a 24-character truncation
* precomputed for narrow screens.
*/
export interface ResolvedAnnotation {
pointIndex: number;
label: string;
color: string;
truncatedLabel: string;
}
/** A vertical marker placed on a visible point. */
export interface ResolvedVerticalMarker {
pointIndex: number;
color: string;
label: string | undefined;
}
/**
* Markers land only on a day the chart has a point for (matched by exact day
* key): a line through a gap in the axis would hang with no data context and
* read as a rendering bug. Off-window markers drop.
*/
export function resolveVerticalMarkerPositions(
markers: Array<{ date: string; label?: string; color?: string }> | undefined,
chartData: Array<{ date: string; timestamp?: number }> | undefined,
): ResolvedVerticalMarker[] {
if (!markers || !chartData || chartData.length === 0) return [];
const indexByDate = new Map<string, number>();
for (const [i, point] of chartData.entries()) {
// Last write wins. A drawn point's `date` is its display label
// ("12.03."), so the day key comes from its timestamp, which a daily
// point holds at noon UTC of its day; a bare `{ date }` (the pure-helper
// tests) is the key.
indexByDate.set(
typeof point.timestamp === "number"
? chartPointDayKey(point.timestamp)
: point.date,
i,
);
}
const out: ResolvedVerticalMarker[] = [];
for (const marker of markers) {
const idx = indexByDate.get(marker.date);
if (idx === undefined) continue;
out.push({
pointIndex: idx,
color: marker.color ?? "var(--success)",
label: marker.label,
});
}
return out;
}
/**
* Annotations snap to the nearest visible point within 7 days; further off
* they drop, or long buckets (monthly aggregation) would pull events from
* months away onto one point and stack their labels.
*/
export function resolveAnnotationPositions(
annotations:
Array<{ date: string; label: string; color: string }> | undefined,
chartData: Array<{ timestamp: number }> | undefined,
): ResolvedAnnotation[] {
if (!annotations || !chartData || chartData.length === 0) return [];
const out: ResolvedAnnotation[] = [];
for (const annotation of annotations) {
const targetMs = Date.parse(`${annotation.date}T12:00:00Z`);
if (!Number.isFinite(targetMs)) continue;
let bestIdx = -1;
let bestDelta = Number.POSITIVE_INFINITY;
for (const [i, point] of chartData.entries()) {
const delta = Math.abs(point.timestamp - targetMs);
if (delta < bestDelta) {
bestDelta = delta;
bestIdx = i;
}
}
if (bestIdx < 0 || bestDelta > 7 * 24 * 60 * 60 * 1000) continue;
const truncatedLabel =
annotation.label.length > 24
? `${annotation.label.slice(0, 23)}…`
: annotation.label;
out.push({
pointIndex: bestIdx,
label: annotation.label,
color: annotation.color,
truncatedLabel,
});
}
return out;
}
+186
View File
@@ -0,0 +1,186 @@
import type { ComponentType } from "react";
import type {
ChartOverlayKey,
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MetricWindowStats } from "@/lib/charts/window-stats";
import type { DataWindow } from "../mini-window";
/** A shaded value band behind the series (an HTML overlay under the plot). */
export interface ChartValueBand {
min: number;
max: number;
color: string;
opacity?: number;
strokeOpacity?: number;
}
/** A target zone drawn inside the plot, with dashed edges and a label. */
export interface ChartTargetZone {
min: number;
max: number;
color: string;
opacity?: number;
label?: string;
textColor?: string;
lineOpacity?: number;
}
export interface HealthChartProps {
types: string[];
title: string;
colors?: string[];
unit?: string;
yAxisUnit?: string;
valueMode?: "raw" | "bmi";
yAxisWidth?: number;
showYAxisUnit?: boolean;
valueBands?: ChartValueBand[];
targetZones?: ChartTargetZone[];
/**
* Compact chart for the rationale card: no range tabs, no overlay toggles,
* tighter padding. Tooltip, gradient and personal baseline stay, so the
* small chart tells the same story.
*/
mini?: boolean;
/**
* Pin the chart to a rationale data window regardless of any parent UI
* state (the recommendation card's mini chart).
*/
windowOverride?: DataWindow;
/**
* "lastMonth" / "lastYear" paints a dimmed prior-period overlay beneath the
* current series, shifted so the prior day lands under its current-period
* sibling. "none" or undefined draws the chart without it.
*/
compareBaseline?: ComparisonBaseline;
/**
* Per-chart overlay-prefs key. With it the chart offers the overlay
* controls and reads its toggles and range from the persisted prefs;
* without it (mini mode, ad-hoc use) the toggles default to off.
*/
chartKey?: ChartOverlayKey;
/**
* Whether a chart bound to a `chartKey` also shows the overlay dropdown.
* Off for a chart that persists only its range tab (the score histories).
* Defaults to on.
*/
overlayControls?: boolean;
/**
* Storyboard annotations: a vertical reference line and label on the point
* whose day key matches `date` (`YYYY-MM-DD`). The series and tooltip are
* untouched; an annotation outside the visible window drops.
*/
annotations?: Array<{ date: string; label: string; color: string }>;
/**
* Injection-day markers: a thin dashed line and a dot on the x axis at the
* point whose day key matches `date`, with no label unless one is given.
* Default colour `var(--success)`. Off-window markers drop.
*/
verticalMarkers?: Array<{ date: string; label?: string; color?: string }>;
/**
* The display time zone for tick labels and day buckets. Without it the
* chart uses the signed-in account's zone, and before that has loaded the
* server's default.
*/
userTimezone?: string;
/**
* Display-time scale applied to every raw value at the one read boundary
* (bucketing, y domain, baseline, trend and tooltip all see the scaled
* series), e.g. m/s → km/h. Defaults to 1.
*/
valueScale?: number;
/**
* Display-time offset folded in beside `valueScale`
* (`display = raw * valueScale + valueOffset`), for °C → °F. Defaults to 0.
* Every delta and slope is a difference, so the offset cancels there.
*/
valueOffset?: number;
/**
* Reports the per-type min / max / median / mean of the data visible under
* the active range tab, on mount and on every change, for the stat strip a
* sub-page lifts it into. Mini mode never reports.
*/
onVisibleStats?: (stats: Record<string, MetricWindowStats> | null) => void;
/**
* Leading glyph for the card's `<TileHeader>`. Without it the card keeps
* the compact heading.
*/
titleIcon?: ComponentType<{ className?: string }>;
/**
* Fires once the data query has settled (success or error), for the
* dashboard's shared reveal. Repeat-safe.
*/
onDataReady?: () => void;
/**
* Pre-fetched daily series keyed by `MeasurementType`, from the dashboard's
* one batched read. Read for the covered types instead of a per-type fetch
* while the active window fits `preloadedCoverageDays`; SLEEP_DURATION is
* never pre-fetched.
*/
preloadedSeries?: Record<string, MeasurementApiRow[]>;
/**
* The day span the `preloadedSeries` slice covers. A wider range tab falls
* through to a real fetch. Required with `preloadedSeries`.
*/
preloadedCoverageDays?: number;
/**
* The drawn points as a table under the chart, behind a collapsed
* disclosure. Opt-in: the metric pages want the numbers, the dashboard
* tiles do not.
*/
showDataTable?: boolean;
/**
* The chart as a door to its days: a click on a point opens that day, the
* open day is marked, and dots under the axis mark the days that hold
* anything. Only while drawn in days, never in `mini` mode.
*/
dayLinks?: boolean;
}
export interface ChartDataPoint {
date: string;
timestamp: number;
pointIndex?: number;
// `${type}__range` keys carry a `[min, max]` tuple for the range band;
// every other key is a scalar.
[key: string]: string | number | undefined | [number, number];
}
export interface MeasurementApiRow {
measuredAt: string;
value: number;
// The day's min / max from the rollup daily aggregate (non-cumulative
// metrics only), for the range band around the mean line. Never drawn on
// the 7-day range. `null` is accepted beside `undefined` because the
// batched series carries `number | null`.
minValue?: number | null;
maxValue?: number | null;
// The raw-row count per bucket, for the "more days needed" copy. Absent on
// the per-night sleep series.
count?: number;
}
export interface VisibleValueBand {
key: string;
topPct: number;
heightPct: number;
color: string;
opacity: number;
strokeOpacity: number;
}
export interface VisibleTargetZone {
key: string;
low: number;
high: number;
topPct: number;
heightPct: number;
color: string;
opacity: number;
label: string | undefined;
textColor: string | undefined;
lineOpacity: number;
}
@@ -0,0 +1,309 @@
"use client";
import { useMemo } from "react";
import {
computeWindowTrend,
SPLIT_HALF_THRESHOLD_DAYS,
} from "@/lib/analytics/window-trend";
import type { MetricWindowStats } from "@/lib/charts/window-stats";
import type { useFormatters } from "@/lib/i18n/context";
import type { ChartDataTableColumn } from "../chart-data-table";
import { computePersonalBaseline, getTypeLabel } from "./series-math";
import type {
ChartDataPoint,
ChartTargetZone,
ChartValueBand,
VisibleTargetZone,
VisibleValueBand,
} from "./types";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
type Formatters = ReturnType<typeof useFormatters>;
/** A band clipped to the y domain, as top / height percentages of the plot. */
function clipToDomain(
min: number,
max: number,
[domainMin, domainMax]: [number, number],
) {
const span = domainMax - domainMin;
const low = Math.max(Math.min(min, max), domainMin);
const high = Math.min(Math.max(min, max), domainMax);
if (high <= low) return null;
return {
low,
high,
topPct: ((domainMax - high) / span) * 100,
heightPct: ((high - low) / span) * 100,
};
}
/**
* The layers drawn over the series: the value bands and target zones
* (clipped to the y domain, only with the target-range toggle on), the
* personal baseline per type, and the trend read-out above the plot.
*/
export function useHealthChartOverlays({
showBands,
showTrend,
valueBands,
targetZones,
yDomain,
chartData,
types,
unit,
valueMode,
t,
fmt,
}: {
showBands: boolean;
showTrend: boolean;
valueBands?: ChartValueBand[];
targetZones?: ChartTargetZone[];
yDomain: [number, number] | undefined;
chartData: ChartDataPoint[] | undefined;
types: string[];
unit?: string;
valueMode: "raw" | "bmi";
t: Translate;
fmt: Formatters;
}) {
const visibleBands = useMemo<VisibleValueBand[]>(() => {
if (!showBands || !valueBands?.length || !yDomain) return [];
const span = yDomain[1] - yDomain[0];
if (!Number.isFinite(span) || span <= 0) return [];
return valueBands
.map((band, index) => {
const clipped = clipToDomain(band.min, band.max, yDomain);
if (!clipped) return null;
return {
key: `${band.min}-${band.max}-${index}`,
topPct: clipped.topPct,
heightPct: clipped.heightPct,
color: band.color,
opacity: band.opacity ?? 0.2,
strokeOpacity: band.strokeOpacity ?? 0.35,
};
})
.filter(
(entry): entry is VisibleValueBand =>
entry !== null && entry.heightPct > 0,
);
}, [showBands, valueBands, yDomain]);
const visibleTargetZones = useMemo<VisibleTargetZone[]>(() => {
if (!showBands || !targetZones?.length || !yDomain) return [];
const span = yDomain[1] - yDomain[0];
if (!Number.isFinite(span) || span <= 0) return [];
const zones: VisibleTargetZone[] = [];
for (const [index, zone] of targetZones.entries()) {
const clipped = clipToDomain(zone.min, zone.max, yDomain);
if (!clipped) continue;
zones.push({
key: `${zone.min}-${zone.max}-${index}`,
...clipped,
color: zone.color,
opacity: zone.opacity ?? 0.3,
label: zone.label ?? undefined,
textColor: zone.textColor ?? undefined,
lineOpacity: zone.lineOpacity ?? 0.42,
});
}
return zones;
}, [showBands, targetZones, yDomain]);
const trendInfo = useMemo(() => {
if (!showTrend || !chartData?.length) return [];
const formatSigned = (value: number) => {
const formatted = fmt.number(Math.abs(value), 1);
return `${value > 0 ? "+" : value < 0 ? "-" : ""}${formatted}`;
};
return types
.map((type) => {
const series = chartData.filter(
(point) =>
typeof point[type] === "number" &&
typeof point[`${type}_trend`] === "number",
);
if (series.length < 2) return null;
const first = series[0];
const last = series[series.length - 1];
const days = (last.timestamp - first.timestamp) / 86400000;
if (!Number.isFinite(days) || days <= 0) return null;
const rawValues = series.map((point) => point[type] as number);
const trendValues = series.map(
(point) => point[`${type}_trend`] as number,
);
const computed = computeWindowTrend({
rawValues,
trendValues,
windowDays: days,
});
if (!computed) return null;
const { weeklyDelta, splitHalfDelta } = computed;
const base = rawValues[0];
const weeklyPct =
Math.abs(base) > Number.EPSILON
? (weeklyDelta / Math.abs(base)) * 100
: null;
const unitSuffix = unit ? ` ${unit}` : "";
// On a long window the per-week slope rounds to ±0.0 and reads as "no
// change", so from SPLIT_HALF_THRESHOLD_DAYS the first-half versus
// second-half mean delta is added: one number that cannot round to
// zero unless the metric truly did not move.
let totalDeltaSegment = "";
if (days >= SPLIT_HALF_THRESHOLD_DAYS && splitHalfDelta !== null) {
const meanFirst =
rawValues
.slice(0, Math.floor(rawValues.length / 2))
.reduce((sum, value) => sum + value, 0) /
Math.max(1, Math.floor(rawValues.length / 2));
const totalDeltaPct =
Math.abs(meanFirst) > Number.EPSILON
? (splitHalfDelta / Math.abs(meanFirst)) * 100
: null;
totalDeltaSegment = ` · ${t(
"charts.totalDelta",
)} ${formatSigned(splitHalfDelta)}${unitSuffix}${
totalDeltaPct != null ? ` (${formatSigned(totalDeltaPct)} %)` : ""
}`;
}
return `${getTypeLabel(type, valueMode, t)}: ${formatSigned(
weeklyDelta,
)}${unitSuffix}${t("charts.perWeek")}${
weeklyPct != null
? ` (${formatSigned(weeklyPct)} %${t("charts.perWeek")})`
: ""
}${totalDeltaSegment}`;
})
.filter((entry): entry is string => entry !== null);
}, [chartData, showTrend, types, unit, valueMode, t, fmt]);
// The personal baseline (rolling 90-point median) per type, a faint dashed
// "your normal" line, so each point reads against the person's own level.
const personalBaselines = useMemo(() => {
if (!chartData?.length) return new Map<string, number>();
const map = new Map<string, number>();
for (const type of types) {
const series = chartData
.filter((d) => typeof d[type] === "number")
.map((d) => ({ value: d[type] as number }));
const baseline = computePersonalBaseline(series, 90);
if (baseline !== null) map.set(type, baseline);
}
return map;
}, [chartData, types]);
return { visibleBands, visibleTargetZones, trendInfo, personalBaselines };
}
/**
* The words around the plot: a spoken summary of the primary series (range,
* latest value, direction) so a screen reader does not meet an unlabelled
* graphic, and the data table's columns, one per series that has a value in
* the window, named by the same helper as the legend.
*/
export function useHealthChartLabels({
types,
valueMode,
t,
fmt,
chartData,
chartDataWithCompare,
visibleStatsByType,
showDataTable,
mini,
}: {
types: string[];
valueMode: "raw" | "bmi";
t: Translate;
fmt: Formatters;
chartData: ChartDataPoint[] | undefined;
chartDataWithCompare: ChartDataPoint[] | undefined;
visibleStatsByType: Record<string, MetricWindowStats> | null;
showDataTable: boolean;
mini: boolean;
}) {
const primaryType = types[0];
const chartAriaLabel = useMemo(() => {
const metric = getTypeLabel(primaryType, valueMode, t);
const source = chartDataWithCompare ?? chartData;
const values = (source ?? [])
.map((point) => point[primaryType])
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (values.length === 0) {
return t("charts.a11y.noData", { metric });
}
const stats = visibleStatsByType?.[primaryType];
const minVal = stats?.min ?? Math.min(...values);
const maxVal = stats?.max ?? Math.max(...values);
const latestVal = values[values.length - 1];
const firstVal = values[0];
const epsilon = Math.max(Math.abs(maxVal - minVal) * 0.02, 0.01);
const delta = latestVal - firstVal;
const trend =
delta > epsilon
? t("charts.a11y.trendUp")
: delta < -epsilon
? t("charts.a11y.trendDown")
: t("charts.a11y.trendFlat");
return t("charts.a11y.summary", {
metric,
min: fmt.number(minVal, 1),
max: fmt.number(maxVal, 1),
latest: fmt.number(latestVal, 1),
trend,
});
}, [
primaryType,
valueMode,
t,
chartDataWithCompare,
chartData,
visibleStatsByType,
fmt,
]);
// A type with no value anywhere in the window gets no column: a column of
// dashes states nothing the chart's own empty line does not.
const dataTableColumns = useMemo<ChartDataTableColumn[]>(() => {
if (!showDataTable || mini) return [];
const source = chartDataWithCompare ?? chartData;
if (!source?.length) return [];
return types
.filter((type) =>
source.some(
(point) =>
typeof point[type] === "number" &&
Number.isFinite(point[type] as number),
),
)
.map((type) => ({ key: type, label: getTypeLabel(type, valueMode, t) }));
}, [
showDataTable,
mini,
chartDataWithCompare,
chartData,
types,
valueMode,
t,
]);
return { primaryType, chartAriaLabel, dataTableColumns };
}
@@ -0,0 +1,360 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { chartSeriesParams } from "@/components/charts/chart-series-request";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiGet } from "@/lib/api/api-fetch";
import type { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import {
localDayKeyFor,
openEndedFetchWindow,
} from "@/lib/charts/fetch-window";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { queryKeys } from "@/lib/query-keys";
import { rangeWindowDays } from "../chart-range-tabs";
import { dayKeyToTimestamp, toDayKey } from "./series-math";
import type { ChartDataPoint, MeasurementApiRow } from "./types";
type BucketValues = Record<
string,
{
sum: number;
count: number;
// The bucket's spread for the range band, from the rollup's min / max
// columns; null when the row carries none (raw rows, cumulative metric).
min: number | null;
max: number | null;
}
>;
/**
* One read of the chart's series, folded to one point per profile-zone day.
* Every value passes the display scale once, here; cumulative types add up
* within a day and the rest average; the rollup's min / max become the range
* band's tuple.
*/
async function readDailySeries({
types,
fetchWindow,
usePreloaded,
preloadedSeries,
valueScale,
valueOffset,
dayKeyFormatter,
tzFmt,
}: {
types: string[];
fetchWindow: ReturnType<typeof openEndedFetchWindow>;
usePreloaded: boolean;
preloadedSeries?: Record<string, MeasurementApiRow[]>;
valueScale: number;
valueOffset: number;
dayKeyFormatter: Intl.DateTimeFormat;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
}): Promise<ChartDataPoint[]> {
const dailyAggregates = new Map<
string,
{ timestamp: number; values: BucketValues }
>();
// SLEEP_DURATION is stored one row per stage per night. The series
// adapter reconstructs ONE point per night carrying the night's time
// asleep in hours (core + deep + REM, bare asleep only without a
// granular stage, in bed and awake excluded, dual-source nights
// collapsed), so every sleep surface shares one reconstruction.
async function fetchSleepNights() {
const sleepParams = new URLSearchParams();
sleepParams.set("kind", "sleep");
// The series route caps sleep at 365 days; clamp the request so a
// longer comparison window still resolves.
sleepParams.set(
"days",
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
);
// A failed read rejects the query rather than caching an empty
// series as success (which painted "no data" with no retry).
const data = await apiGet<{
points?: Array<{ at: string; value: number }>;
}>(`/api/measurements/series?${sleepParams}`);
const points = data?.points ?? [];
for (const point of points) {
const value = point.value * valueScale + valueOffset;
if (value == null || !Number.isFinite(value)) continue;
const dayKey = toDayKey(point.at, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values["SLEEP_DURATION"] ?? {
sum: 0,
count: 0,
min: null,
max: null,
};
current.sum += value;
current.count += 1;
bucket.values["SLEEP_DURATION"] = current;
dailyAggregates.set(dayKey, bucket);
}
}
async function fetchMeasurementsByType(type: string) {
if (type === "SLEEP_DURATION") {
await fetchSleepNights();
return;
}
const typeParams = chartSeriesParams(type, fetchWindow);
// The batched slice reads through the same rollup path as the
// per-type read, so the bucketed result is identical. A covered type
// missing from it paints the no-data card rather than fetching.
const page =
usePreloaded && preloadedSeries
? (preloadedSeries[type] ?? [])
: await (async () => {
// A failed read rejects the whole query (see above).
const data = await apiGet<{
measurements?: MeasurementApiRow[];
}>(`/api/measurements?${typeParams}`);
return data?.measurements ?? [];
})();
for (const measurement of page) {
// The display scale folds into the value at this one boundary, so
// everything downstream works on the scaled series. The BMI
// division happens in `select`, so the cache stays raw.
const value = measurement.value * valueScale + valueOffset;
if (!Number.isFinite(value)) {
continue;
}
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? {
sum: 0,
count: 0,
min: null,
max: null,
};
current.sum += value;
current.count += 1;
// The rollup bucket's spread, scaled like the value so the band
// tracks the line. The 7-day range never draws the band.
if (
fetchWindow.windowDays > 7 &&
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number"
) {
const scaledMin = measurement.minValue * valueScale + valueOffset;
const scaledMax = measurement.maxValue * valueScale + valueOffset;
current.min =
current.min === null ? scaledMin : Math.min(current.min, scaledMin);
current.max =
current.max === null ? scaledMax : Math.max(current.max, scaledMax);
}
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
}
}
await Promise.all(types.map(fetchMeasurementsByType));
const allData: ChartDataPoint[] = Array.from(dailyAggregates.values())
.map((bucket) => {
const point: ChartDataPoint = {
date: tzFmt.dateShortSmart(new Date(bucket.timestamp)),
timestamp: bucket.timestamp,
};
for (const [type, stats] of Object.entries(bucket.values)) {
// Cumulative types (steps, active energy, distance, flights,
// daylight) add up within a day; everything else averages.
const isCumulative = CUMULATIVE_HK_TYPES.has(type as MeasurementType);
point[type] = isCumulative ? stats.sum : stats.sum / stats.count;
// The day's spread as a `[min, max]` tuple for the range band,
// when the rollup supplied both bounds and the metric is not a
// sum (a total has no meaningful intra-day spread).
if (!isCumulative && stats.min !== null && stats.max !== null) {
point[`${type}__range`] = [stats.min, stats.max] as never;
}
}
return point;
})
.sort((a, b) => a.timestamp - b.timestamp);
return allData;
}
/**
* The chart's daily series: one point per day in the profile time zone,
* read from the window the active range tab needs.
*
* The window is bounded by the range tab (the "All" tab asks for about ten
* years; the server steps up to week and month buckets past its day cap) and
* stretched backwards by the comparison shift so the prior period rides the
* same read. It ends at the end of TODAY in the profile zone, not at the mount
* instant, so a reading saved after mount falls inside the window the
* invalidated query refetches, and the cache key stays put all day.
*
* On the dashboard the batched slice stands in for the per-type reads, but
* only for windows over 7 days that fit the batch's coverage and only when
* every non-sleep type is in it; sleep always reads its own per-night series.
*/
export function useHealthChartSeries({
types,
valueMode,
bmiDivisor,
valueScale,
valueOffset,
userTimezone,
dayKeyFormatter,
tzFmt,
rangePoints,
effectiveCompareBaseline,
preloadedSeries,
preloadedCoverageDays,
enabled,
onDataReady,
}: {
types: string[];
valueMode: "raw" | "bmi";
bmiDivisor: number | null;
valueScale: number;
valueOffset: number;
userTimezone: string;
dayKeyFormatter: Intl.DateTimeFormat;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
rangePoints: number;
effectiveCompareBaseline: ComparisonBaseline;
preloadedSeries?: Record<string, MeasurementApiRow[]>;
preloadedCoverageDays?: number;
enabled: boolean;
onDataReady?: () => void;
}) {
const todayKey = localDayKeyFor(new Date(), userTimezone);
const fetchWindow = useMemo(() => {
const windowDays = rangeWindowDays(rangePoints);
const compareShift =
effectiveCompareBaseline === "lastMonth"
? 30
: effectiveCompareBaseline === "lastYear"
? 365
: 0;
return openEndedFetchWindow(
windowDays + compareShift,
todayKey,
userTimezone,
);
}, [rangePoints, effectiveCompareBaseline, userTimezone, todayKey]);
// Without an explicit coverage (a legacy mount) the slice is bypassed
// rather than trusted blindly.
const usePreloaded = useMemo(() => {
if (!preloadedSeries) return false;
if (fetchWindow.windowDays <= 7) return false;
const coverage = preloadedCoverageDays ?? 0;
if (fetchWindow.windowDays > coverage) return false;
return types.every(
(type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null,
);
}, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]);
const chartQuery = useQuery({
// A range-tab change re-keys the query; without placeholder data every
// switch dropped the painted chart to a skeleton for the round trip. It
// is only honest because the chart dims while `isPlaceholderData` is
// true: the old range must never masquerade as the new one.
placeholderData: keepPreviousData,
// The `["chart-data"]` prefix lands in `measurementDependentKeys`, so a
// new measurement evicts every chart's cache in one pass. The BMI view
// shares the raw WEIGHT entry (the division happens in `select`), the
// time zone and the window re-key it, and the discriminator separates a
// batched render from a self-fetch only on mounts that pass a batch (an
// empty string keeps every other caller's key as it was).
queryKey: queryKeys.chartData(
types.join(","),
"raw",
"no-bmi",
userTimezone,
fetchWindow.from,
fetchWindow.to,
valueScale,
preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "",
),
// A minute of freshness so moving between metric pages does not refire
// every chart; five minutes of cache for a quick back-and-forth.
staleTime: 60_000,
gcTime: 5 * 60_000,
queryFn: () =>
readDailySeries({
types,
fetchWindow,
usePreloaded,
preloadedSeries,
valueScale,
valueOffset,
dayKeyFormatter,
tzFmt,
}),
// The BMI view: the cached raw WEIGHT series divided at read time, so the
// entry is shared with the weight chart. No height → no series. The range
// tuples are dropped; the BMI view has never drawn the band.
select: useCallback(
(points: ChartDataPoint[]): ChartDataPoint[] => {
if (valueMode !== "bmi") return points;
if (!bmiDivisor) return [];
return points.map((p) => {
const out: ChartDataPoint = {
date: p.date,
timestamp: p.timestamp,
};
for (const type of types) {
const v = p[type];
if (typeof v === "number") {
out[type] = v / bmiDivisor;
}
}
return out;
});
},
[valueMode, bmiDivisor, types],
),
enabled,
});
// Report the settled first read to the dashboard's shared reveal, exactly
// once. Latched behind a ref and keyed on `isLoading` alone: the dashboard
// passes an inline closure, and re-running the notify on every commit kept
// the chart row's popper anchors re-committing until React's update-depth
// guard tripped (#185). A late-arriving handler still fires.
const { isLoading } = chartQuery;
const onDataReadyRef = useRef(onDataReady);
useEffect(() => {
onDataReadyRef.current = onDataReady;
}, [onDataReady]);
const dataReadyFiredRef = useRef(false);
useEffect(() => {
if (
!shouldFireDataReady({
isLoading,
alreadyFired: dataReadyFiredRef.current,
})
)
return;
dataReadyFiredRef.current = true;
onDataReadyRef.current?.();
}, [isLoading]);
return chartQuery;
}
@@ -0,0 +1,347 @@
"use client";
import { useEffect, useMemo } from "react";
import { bucketCaptionKey } from "@/lib/charts/bucket-caption";
import type { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import {
bucketTimeSeries,
pickBucket,
type ChartBucketType,
} from "@/lib/charts/bucket-time-series";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
import {
computeWindowStats,
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
import { trendLineByTime } from "@/lib/stats/linear-fit";
import { movingAverageByPoints } from "./series-math";
import type { ChartDataPoint } from "./types";
/**
* The visible slice at its grain, indexed for the x axis, with the 7-day
* moving average and the trend line stamped on as `${type}_ma` and
* `${type}_trend` when those overlays are on.
*/
function bucketVisibleSeries(
{
sliced,
bucketType,
}: { sliced: ChartDataPoint[]; bucketType: ChartBucketType },
{
types,
userTimezone,
tzFmt,
showMA,
showTrend,
}: {
types: string[];
userTimezone: string;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
showMA: boolean;
showTrend: boolean;
},
): ChartDataPoint[] {
const bucketed =
bucketType === "day"
? sliced
: bucketTimeSeries(
sliced.map((p) => ({
timestamp: p.timestamp,
values: Object.fromEntries(
types.map((type) => [type, p[type] as number | undefined]),
),
})),
{ bucket: bucketType, timeZone: userTimezone },
).points.map<ChartDataPoint>((point) => {
const date = new Date(point.timestamp);
const out: ChartDataPoint = {
date: tzFmt.dateShortSmart(date),
timestamp: point.timestamp,
};
for (const [type, value] of Object.entries(point.values)) {
out[type] = value;
}
return out;
});
const enriched: ChartDataPoint[] = bucketed.map((d, index) => ({
...d,
pointIndex: index,
}));
if (showMA) {
for (const type of types) {
const typeData = enriched
.filter((d) => d[type] !== undefined)
.map((d) => ({
date: new Date(d.timestamp),
value: d[type] as number,
}));
if (typeData.length >= 2) {
const ma = movingAverageByPoints(typeData, 7);
for (const point of ma) {
const pointTimestamp = point.date.getTime();
const existing = enriched.find((d) => d.timestamp === pointTimestamp);
if (existing) {
existing[`${type}_ma`] = point.value;
}
}
}
}
}
if (showTrend) {
for (const type of types) {
const typeData = enriched
.filter((d) => d[type] !== undefined)
.map((d) => ({
date: new Date(d.timestamp),
value: d[type] as number,
}));
if (typeData.length >= 2) {
const trend = trendLineByTime(typeData);
if (trend) {
for (const point of trend.points) {
const pointTimestamp = point.date.getTime();
const existing = enriched.find(
(d) => d.timestamp === pointTimestamp,
);
if (existing) {
existing[`${type}_trend`] = point.value;
}
}
}
}
}
}
return enriched;
}
/**
* The prior period stamped onto the visible points as `${type}_compare`: the
* fetched daily series shifted forward by 30 / 365 days, matched on the same
* day key the chart uses. A prior point paints only where its shifted day is
* a visible day.
*/
function mergePriorPeriod(
chartData: ChartDataPoint[],
data: ChartDataPoint[],
{
types,
tzFmt,
baseline,
}: {
types: string[];
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
baseline: Exclude<ComparisonBaseline, "none">;
},
): ChartDataPoint[] {
const shifted = shiftDailySeriesForward(
data.map((row) => ({
timestamp: row.timestamp,
values: Object.fromEntries(
types.map((type) => [type, row[type] as number | undefined]),
),
})),
baseline,
);
// Indexed by the same day key the chart already uses.
const shiftedByDay = new Map<string, Record<string, number>>();
for (const row of shifted) {
const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp));
const slot = shiftedByDay.get(dayKey) ?? {};
for (const [type, value] of Object.entries(row.values)) {
if (typeof value === "number" && Number.isFinite(value)) {
slot[type] = value;
}
}
shiftedByDay.set(dayKey, slot);
}
return chartData.map((point) => {
const compareValues = shiftedByDay.get(point.date);
if (!compareValues) return point;
const merged: ChartDataPoint = { ...point };
for (const type of types) {
const v = compareValues[type];
if (typeof v === "number" && Number.isFinite(v)) {
merged[`${type}_compare`] = v;
}
}
return merged;
});
}
/**
* What the chart draws from its daily series: the visible window, bucketed
* to day, week or month, with the moving average, the trend line and the
* prior-period overlay merged in, and the y domain that fits all of it.
*/
export function useHealthChartView({
data,
rangePoints,
types,
userTimezone,
tzFmt,
showMA,
showTrend,
effectiveCompareBaseline,
mini,
isLoading,
isError,
onVisibleStats,
}: {
data: ChartDataPoint[] | undefined;
rangePoints: number;
types: string[];
userTimezone: string;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
showMA: boolean;
showTrend: boolean;
effectiveCompareBaseline: ComparisonBaseline;
mini: boolean;
isLoading: boolean;
isError: boolean;
onVisibleStats?: (stats: Record<string, MetricWindowStats> | null) => void;
}) {
// The grain (day / week / month) is picked from the VISIBLE range, so a
// 30-day tab stays daily on a five-year-old account, and it has ONE source:
// the points, the caption chip and the data table all read it. The slice
// is a calendar-day window, as the range tabs promise and the fetch
// requests, on the self-fetched and the batched path alike.
const visibleSlice = useMemo(() => {
if (!data?.length) return null;
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sliced =
rangePoints > 0
? data.filter((point) => point.timestamp >= cutoff)
: [...data];
// An empty window stays an empty slice ("no data in this range");
// returning null would quietly widen the window to the whole series.
const rangeDays =
sliced.length < 2
? 0
: Math.round(
(sliced[sliced.length - 1].timestamp - sliced[0].timestamp) /
(24 * 60 * 60 * 1000),
);
return { sliced, bucketType: pickBucket(rangeDays) };
}, [data, rangePoints]);
const chartData = useMemo(() => {
if (!data?.length || !visibleSlice) return data;
return bucketVisibleSeries(visibleSlice, {
types,
userTimezone,
tzFmt,
showMA,
showTrend,
});
}, [data, visibleSlice, showMA, showTrend, types, tzFmt, userTimezone]);
// Min / max / median / mean of what is visible under the active range tab,
// for the stat strip a sub-page lifts the callback into. Mini mode has no
// stat strip.
const visibleStatsByType = useMemo<Record<
string,
MetricWindowStats
> | null>(() => {
if (mini || !chartData?.length) return null;
const out: Record<string, MetricWindowStats> = {};
for (const type of types) {
const stats = computeWindowStats(
chartData.map((point) => point[type] as number | undefined),
);
if (stats.count > 0) out[type] = stats;
}
return Object.keys(out).length > 0 ? out : null;
}, [mini, chartData, types]);
// An effect, so the parent's state update never runs during this render,
// and only once the read settled: a report during the first fetch would
// tell the strip "no window" and it would paint the full-range summary
// first. A failed read settles too, so the strip never waits forever.
useEffect(() => {
if (mini || isLoading) return;
onVisibleStats?.(isError ? null : visibleStatsByType);
}, [mini, isLoading, isError, visibleStatsByType, onVisibleStats]);
// The prior period, from the already-fetched daily series shifted forward
// by 30 / 365 days, stamped onto the visible points under `${type}_compare`.
// A prior point paints only where its shifted day is a visible day.
const chartDataWithCompare = useMemo(() => {
if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.length) return chartData;
return mergePriorPeriod(chartData, data, {
types,
tzFmt,
baseline: effectiveCompareBaseline,
});
}, [chartData, effectiveCompareBaseline, data, types, tzFmt]);
// True when at least one visible day has a prior value. A partial overlay
// counts as available, so the "comparison unavailable" caption never sits
// beside dimmed history the reader can see.
const hasComparisonData = useMemo(() => {
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) =>
types.some(
(type) =>
typeof point[`${type}_compare`] === "number" &&
Number.isFinite(point[`${type}_compare`] as number),
),
);
}, [chartDataWithCompare, effectiveCompareBaseline, types]);
const activeBucket: ChartBucketType = visibleSlice?.bucketType ?? "day";
// What a folded point is: the average day of its week or month (a daily
// total's average for steps and the like), named on the caption chip and
// in the tooltip.
const bucketCaption = bucketCaptionKey(activeBucket, types);
const yDomain = useMemo<[number, number] | undefined>(() => {
if (!chartDataWithCompare?.length) return undefined;
const keys = [...types];
if (showMA) keys.push(...types.map((type) => `${type}_ma`));
if (showTrend) keys.push(...types.map((type) => `${type}_trend`));
// The prior period may sit on a different scale; without it the dimmed
// line clips outside the plot.
if (effectiveCompareBaseline !== "none") {
keys.push(...types.map((type) => `${type}_compare`));
}
const values = chartDataWithCompare
.flatMap((point) => keys.map((key) => point[key]))
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value));
return computePaddedYDomain(values, types);
}, [
chartDataWithCompare,
effectiveCompareBaseline,
showMA,
showTrend,
types,
]);
return {
visibleSlice,
chartData,
visibleStatsByType,
chartDataWithCompare,
hasComparisonData,
activeBucket,
bucketCaption,
yDomain,
};
}
@@ -8,11 +8,15 @@
* (`TILE_CAPABLE_WIDGET_IDS`) and from the dashboard's own render code, and
* holds every tile against the sub-page registry.
*/
import { existsSync, readFileSync } from "node:fs";
import { existsSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
import {
TILE_CAPABLE_WIDGET_IDS,
type TileCapableWidgetId,
@@ -73,19 +77,38 @@ const NON_MEASUREMENT_DESTINATION: Partial<
*/
const NO_DESTINATION: Partial<Record<TileCapableWidgetId, string>> = {};
/** Tile ids the dashboard pushes into its strip, read from the render code. */
/**
* Card ids the strip tiles paint, built from the registry with a context in
* which every series has a reading, so every tile paints its cards.
*/
function renderedTileIds(): string[] {
const source = readFileSync(
path.join(ROOT, "src/app/page-client.tsx"),
"utf8",
);
const ids: string[] = [];
const pattern =
/trendCards\.push\(\{\s*id:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g;
for (const match of source.matchAll(pattern)) {
ids.push(match[1] ?? `${match[2]}*`);
}
return ids;
const reading = { count: 3, latest: 1, avg7: 1, avg30: 1 };
const summaries = new Proxy({} as Record<string, typeof reading>, {
get: () => reading,
});
const ctx = {
t: (key: string) => key,
fmt: { number: (v: number) => String(v) },
td: (_type: string, v: number | null | undefined) => v ?? null,
tdd: (_type: string, v: number | null | undefined) => v ?? null,
unitDisplay: { unitFor: () => "" },
user: null,
summaries,
moodSummary: reading,
hrv: { summary: reading, type: "HRV_RMSSD", labelKey: "x" },
data: { bpInTargetPct: 50, bpInTargetPct7d: 50, bpInTargetPct30d: 50 },
glucoseTiles: [{ bucket: "FASTING", labelKey: "x", summary: reading }],
displayGlucoseUnit: "mg/dL",
weightTrend: { direction: "neutral" },
compareBaseline: "none",
directionOf: () => "neutral",
tileCompareDelta: () => null,
tileStaleDays: () => null,
} as unknown as DashboardTileContext;
return STRIP_TILES.flatMap((tile) => {
expect(tile.visible(ctx), tile.widget).toBe(true);
return tile.cards(ctx).map((card) => card.id);
});
}
describe("dashboard tile destinations", () => {
@@ -95,11 +118,15 @@ describe("dashboard tile destinations", () => {
});
it("knows every tile the dashboard renders, and renders every known tile", () => {
// The registry names each tile's widget; every tile-capable widget has a
// tile, and every card a tile paints maps back to a known widget.
expect(STRIP_TILES.map((tile) => tile.widget).sort()).toEqual(
[...TILE_CAPABLE_WIDGET_IDS].sort(),
);
const rendered = renderedTileIds();
const widgets = new Set<string>();
for (const id of rendered) {
const sample = id.endsWith("*") ? `${id.slice(0, -1)}X` : id;
const widget = tileWidgetId(sample);
const widget = tileWidgetId(id);
expect(widget, `rendered tile "${id}" has no widget`).not.toBeNull();
widgets.add(widget as string);
}
@@ -107,10 +134,7 @@ describe("dashboard tile destinations", () => {
});
it("routes the dashboard's tile links through this map and no other", () => {
const source = readFileSync(
path.join(ROOT, "src/app/page-client.tsx"),
"utf8",
);
const source = dashboardSource();
expect(source).toMatch(/dashboardTileHref\(entry\.id,/);
expect(source).not.toMatch(/"\/insights\/[a-z-]+"/);
});
@@ -0,0 +1,373 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import dynamic from "next/dynamic";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
import { BATCH_COVERAGE_DAYS } from "@/lib/dashboard/batch-chart-types";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import { importWithRetry } from "@/lib/retry-import";
import type { DashboardTileContext } from "./tile-context";
import type { BatchedSeriesRow } from "./use-dashboard-data";
// The loaders retry a rejected chunk import once (a lazy import caches its
// rejection for good, so a transient 404 from a stale shell would brick the
// card for the session), and each mount sits in a `<ChartErrorBoundary>` so a
// chunk that still fails degrades to one error card instead of the route's
// `error.tsx`.
const MoodChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.MoodChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const MoodChart = (props: ComponentProps<typeof MoodChartLazy>) => (
<ChartErrorBoundary>
<MoodChartLazy {...props} />
</ChartErrorBoundary>
);
const MedicationComplianceChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.MedicationComplianceChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const MedicationComplianceChart = (
props: ComponentProps<typeof MedicationComplianceChartLazy>,
) => (
<ChartErrorBoundary>
<MedicationComplianceChartLazy {...props} />
</ChartErrorBoundary>
);
// Warm the shared `chart-runtime` chunk during hydration. The charts mount
// through `ssr: false` dynamics, which start their import only at mount;
// firing it at module evaluation lets the download overlap hydration. The
// dynamics resolve through the same `importWithRetry` promise.
if (typeof window !== "undefined") {
void importWithRetry(() => import("@/components/charts/chart-runtime"));
}
/** What a chart card needs beyond the tile context. */
export interface ChartTileContext extends DashboardTileContext {
preloadedSeries: Record<string, BatchedSeriesRow[]> | undefined;
markChartReady: (id: string) => void;
}
/** One card in the chart row, positioned by `order`. */
export interface ChartEntry {
id: string;
order: number;
node: ReactNode;
/**
* Total raw readings for the metric. Under five, a "first trend after 5
* readings" hint sits under the chart; undefined disables the hint.
*/
count?: number;
/**
* The cell holds its skeleton until every gated chart's data settled (or
* the reveal timeout fired). Data-backed charts set this; the cards that
* skeleton themselves do not.
*/
revealGated?: boolean;
}
/**
* A chart-row card: the layout widget that switches and orders it, the card's
* own rule (a data floor, a module, a resolved layout), and the card itself.
* Unlike the strip, the chart row reads the widget's chart flag (`visible`),
* which `buildChartEntries` applies for every card.
*/
interface ChartTile {
id: string;
widget: string;
orderOffset?: number;
revealGated?: boolean;
visible: (ctx: DashboardTileContext) => boolean;
count?: (ctx: DashboardTileContext) => number;
render: (ctx: ChartTileContext) => ReactNode;
}
const countOf = (summary: { count?: number } | null | undefined) =>
summary?.count ?? 0;
const BMI_BANDS = [
{ min: 0, max: 17, color: "var(--destructive)", opacity: 0.16 },
{ min: 17, max: 18.5, color: "var(--warning)", opacity: 0.18 },
{ min: 18.5, max: 24.9, color: "var(--success)", opacity: 0.2 },
{ min: 24.9, max: 29.9, color: "var(--warning)", opacity: 0.18 },
{ min: 29.9, max: 120, color: "var(--destructive)", opacity: 0.16 },
];
const hasRestingHr = (ctx: DashboardTileContext) =>
countOf(ctx.summaries?.RESTING_HEART_RATE) > 0;
/**
* A measurement chart card with the props every dashboard chart shares: its
* reveal report, the comparison baseline, day doors and, unless the card
* says otherwise, the batched series slice and the profile time zone.
*/
function metricChart(
ctx: ChartTileContext,
id: string,
props: Omit<
ComponentProps<typeof HealthChartDynamic>,
"onDataReady" | "compareBaseline" | "dayLinks"
>,
{ batched = true, timezone = true } = {},
): ReactNode {
return (
<HealthChartDynamic
key={id}
onDataReady={() => ctx.markChartReady(id)}
{...(batched
? {
preloadedSeries: ctx.preloadedSeries,
preloadedCoverageDays: BATCH_COVERAGE_DAYS,
}
: {})}
compareBaseline={ctx.compareBaseline}
{...(timezone ? { userTimezone: ctx.user?.timezone } : {})}
dayLinks
{...props}
/>
);
}
/** Every chart-row card, in the order the cards are collected. */
export const CHART_TILES: readonly ChartTile[] = [
{
id: "weight-chart",
widget: "weight",
revealGated: true,
visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0,
count: (ctx) => ctx.summaries?.WEIGHT?.count ?? 0,
render: (ctx) =>
metricChart(ctx, "weight-chart", {
chartKey: "weight",
types: ["WEIGHT"],
title: ctx.t("dashboard.weight"),
colors: ["var(--chart-1)"],
unit: ctx.unitDisplay.unitFor("WEIGHT"),
valueBands: ctx.weightDisplayBands,
valueScale: ctx.unitDisplay.transformFor("WEIGHT").factor,
}),
},
{
// BMI rides the weight widget, right behind its chart, and only for a
// profile that carries a height.
id: "bmi-chart",
widget: "weight",
orderOffset: 0.5,
revealGated: true,
visible: (ctx) =>
countOf(ctx.summaries?.WEIGHT) > 0 && Boolean(ctx.user?.heightCm),
render: (ctx) =>
metricChart(
ctx,
"bmi-chart",
{
chartKey: "bmi",
types: ["WEIGHT"],
title: ctx.t("targets.bmi"),
colors: ["var(--dracula-yellow)"],
unit: "kg/m²",
valueMode: "bmi",
valueBands: BMI_BANDS,
},
{ timezone: false },
),
},
{
id: "bp-chart",
widget: "bp",
revealGated: true,
visible: (ctx) =>
countOf(ctx.summaries?.BLOOD_PRESSURE_SYS) > 0 ||
countOf(ctx.summaries?.BLOOD_PRESSURE_DIA) > 0,
count: (ctx) =>
Math.max(
ctx.summaries?.BLOOD_PRESSURE_SYS?.count ?? 0,
ctx.summaries?.BLOOD_PRESSURE_DIA?.count ?? 0,
),
render: (ctx) =>
metricChart(ctx, "bp-chart", {
chartKey: "bp",
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
title: ctx.t("dashboard.bloodPressure"),
colors: ["var(--chart-3)", "var(--chart-4)"],
unit: "mmHg",
targetZones: ctx.bpTargetZones,
}),
},
{
// The resting series against the resting band when there is one;
// otherwise raw heart rate WITHOUT the band, which would mark expected
// workout heart rate as outside target.
id: "pulse-chart",
widget: "pulse",
revealGated: true,
visible: (ctx) =>
countOf(ctx.summaries?.PULSE) > 0 ||
countOf(ctx.summaries?.RESTING_HEART_RATE) > 0,
count: (ctx) => ctx.summaries?.PULSE?.count ?? 0,
render: (ctx) =>
metricChart(ctx, "pulse-chart", {
chartKey: "pulse",
types: hasRestingHr(ctx) ? ["RESTING_HEART_RATE"] : ["PULSE"],
title: hasRestingHr(ctx)
? ctx.t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE)
: ctx.t("dashboard.pulse"),
colors: ["var(--success)"],
unit: "bpm",
valueBands: hasRestingHr(ctx) ? ctx.pulseBands : undefined,
}),
},
{
id: "bodyFat-chart",
widget: "bodyFat",
revealGated: true,
visible: (ctx) => countOf(ctx.summaries?.BODY_FAT) > 0,
count: (ctx) => ctx.summaries?.BODY_FAT?.count ?? 0,
render: (ctx) =>
metricChart(ctx, "bodyFat-chart", {
chartKey: "bodyFat",
types: ["BODY_FAT"],
title: ctx.t("dashboard.bodyFat"),
colors: ["var(--warning)"],
unit: "%",
valueBands: ctx.bodyFatBands,
}),
},
{
id: "mood-chart",
widget: "mood",
revealGated: true,
visible: (ctx) => countOf(ctx.moodSummary) > 0,
count: (ctx) => ctx.moodSummary?.count ?? 0,
render: (ctx) => (
<MoodChart
key="mood-chart"
onDataReady={() => ctx.markChartReady("mood-chart")}
compareBaseline={ctx.compareBaseline}
chartKey="mood"
dayLinks
/>
),
},
{
// Sleep is never batched: it reads its own per-night series.
id: "sleep-chart",
widget: "sleep",
revealGated: true,
visible: (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0,
count: (ctx) => ctx.summaries?.SLEEP_DURATION?.count ?? 0,
render: (ctx) =>
metricChart(
ctx,
"sleep-chart",
{
chartKey: "sleep",
types: ["SLEEP_DURATION"],
title: ctx.t("dashboard.sleep") ?? "Sleep",
colors: ["var(--chart-4)"],
unit: "h",
},
{ batched: false },
),
},
{
id: "steps-chart",
widget: "steps",
revealGated: true,
visible: (ctx) => countOf(ctx.summaries?.ACTIVITY_STEPS) > 0,
count: (ctx) => ctx.summaries?.ACTIVITY_STEPS?.count ?? 0,
render: (ctx) =>
metricChart(ctx, "steps-chart", {
chartKey: "steps",
types: ["ACTIVITY_STEPS"],
title: ctx.t("dashboard.steps") ?? "Steps",
colors: ["var(--success)"],
}),
},
{
// Adherence over `/api/medications/intake?scope=compliance&days=N`.
id: "medications",
widget: "medications",
revealGated: true,
visible: () => true,
render: (ctx) => (
<MedicationComplianceChart
key="medications"
onDataReady={() => ctx.markChartReady("medications")}
compareBaseline={ctx.compareBaseline}
userTimezone={ctx.user?.timezone}
dayLinks
/>
),
},
{
// The preventive-care summary: fetches its own list and skeletons itself,
// so it stays out of the reveal gate.
id: "vorsorge",
widget: "vorsorge",
visible: () => true,
render: () => <VorsorgeDashboardCard key="vorsorge" />,
},
{
// No data floor (the empty card is how the feature is discovered), so it
// waits for the real layout: on the fallback layout it would flash in and
// retract for a person who switched it off.
id: "achievements",
widget: "achievements",
visible: (ctx) => ctx.layoutResolved,
render: () => <RecentAchievementsCard key="achievements" />,
},
{
// Same layout wait as the achievements card; the tile gates on its own
// query and shows the Apple Health hint when empty.
id: "recentWorkouts",
widget: "recentWorkouts",
visible: (ctx) =>
ctx.layoutResolved && ctx.user?.modules?.workouts !== false,
render: () => <RecentWorkoutsTile key="recentWorkouts" />,
},
];
/** The chart-row cards whose widget chart flag is on and whose rule passes. */
export function visibleChartTiles(
ctx: DashboardTileContext,
): readonly ChartTile[] {
return CHART_TILES.filter(
(tile) => ctx.isChartVisible(tile.widget) && tile.visible(ctx),
);
}
/** The ids the shared chart reveal waits for, in card order. */
export function revealChartIds(tiles: readonly ChartTile[]): string[] {
return tiles.filter((tile) => tile.revealGated).map((tile) => tile.id);
}
/** The chart row for this render, sorted by layout order. */
export function buildChartEntries(
tiles: readonly ChartTile[],
ctx: ChartTileContext,
): ChartEntry[] {
const entries: ChartEntry[] = tiles.map((tile) => ({
id: tile.id,
order: ctx.widgetOrder(tile.widget) + (tile.orderOffset ?? 0),
...(tile.count ? { count: tile.count(ctx) } : {}),
...(tile.revealGated ? { revealGated: true } : {}),
node: tile.render(ctx),
}));
entries.sort((a, b) => a.order - b.order);
return entries;
}
@@ -0,0 +1,196 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { Activity, Plus } from "lucide-react";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { TrendHint } from "@/components/charts/trend-hint";
import { DashboardChartCell } from "@/components/dashboard/chart-reveal";
import {
resolveChartRowPlaceholderCount,
resolveConfiguredTileCount,
resolveDashboardFirstPaintGate,
} from "@/components/dashboard/dashboard-gates";
import { dashboardTileHref } from "@/components/dashboard/tile-destinations";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import type { AuthUser } from "@/hooks/use-auth";
import type { DashboardLayout } from "@/lib/dashboard-layout";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ChartEntry } from "./chart-tiles";
import type { StripEntry } from "./strip-tiles";
/**
* The tile strip and the chart row, with the states around them: the
* layout-stable silhouettes while the primary read is in flight, and the
* empty state once it resolved with nothing to show.
*/
export function DashboardTiles({
stripEntries,
chartEntries,
chartsRevealed,
layout,
primaryLoading,
mounted,
user,
canAddMeasurement,
onAddMeasurement,
}: {
stripEntries: StripEntry[];
chartEntries: ChartEntry[];
chartsRevealed: boolean;
layout: DashboardLayout;
primaryLoading: boolean;
mounted: boolean;
user: AuthUser | null;
canAddMeasurement: boolean;
onAddMeasurement: () => void;
}) {
const { t } = useTranslations();
// The skeleton strip reserves the footprint of the tiles the layout
// configures (tile-capable widgets only, two for blood pressure), so the
// strip does not reshuffle when the data lands.
const configuredTileCount = resolveConfiguredTileCount(layout);
// While the snapshot is in flight the data-gated charts cannot exist yet;
// silhouettes for the charts the layout expects hold the page near its
// final height, and the landing snapshot swaps content instead of growing
// the page. `mounted &&` pins the hydration render to the server output:
// the shell's auth read can already carry a height when this boundary
// replays its first render, and an extra BMI silhouette would mismatch
// the server HTML (React #418).
const chartRowPlaceholderCount = primaryLoading
? Math.max(
0,
resolveChartRowPlaceholderCount(layout, {
hasHeightCm: mounted && Boolean(user?.heightCm),
}) - chartEntries.length,
)
: 0;
const { showTileStripSkeleton, showEmptyState } =
resolveDashboardFirstPaintGate({
trendCardCount: stripEntries.length,
chartCount: chartEntries.length,
configuredTileCount,
primaryLoading,
});
// Nothing to show once the read resolved: one call to action into the
// quick-entry sheet instead of an empty strip under the header.
if (showEmptyState) {
return (
<EmptyState
icon={<Activity className="size-6" />}
title={t("dashboard.emptyTitle")}
description={t("dashboard.emptyDescription")}
action={
canAddMeasurement ? (
<Button size="sm" onClick={onAddMeasurement}>
<Plus className="h-4 w-4" />
{t("dashboard.emptyAddMeasurement")}
</Button>
) : undefined
}
/>
);
}
return (
<>
{showTileStripSkeleton && (
<div
// Mirrors the real grid track, keyed off the configured tile count:
// a strip trimmed to three tiles shows three silhouettes.
aria-hidden="true"
data-slot="dashboard-tile-strip-skeleton"
className={cn(
"grid auto-rows-fr gap-3",
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
)}
>
{Array.from({ length: configuredTileCount }).map((_, idx) => (
<TrendCardSkeleton key={`tile-skeleton-${idx}`} />
))}
</div>
)}
{stripEntries.length > 0 && (
<div
// One grid track for every breakpoint, never a second row of
// scrolling: `auto-fit` with an 11 rem floor gives one column on a
// 280 px fold, two on a phone, six at 1440 px and eight from
// 1920 px, and `auto-rows-fr` keeps every row on one height so a
// tile with a callout does not break the baseline. With every tile
// switched off the strip is not rendered at all.
className={cn(
"grid auto-rows-fr gap-3",
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
)}
data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip"
data-tile-count={stripEntries.length}
>
{stripEntries.map((entry) => {
// A layout-stable fallback per tile, so a tile slot that ever
// suspends previews its shape instead of leaving the cell empty.
const cell = (
<Suspense fallback={<TrendCardSkeleton />}>{entry.node}</Suspense>
);
// A tile links to its Insights page (gated on that page's
// module); the link fills the cell and rings on hover and focus.
const href = dashboardTileHref(entry.id, user?.modules);
return href ? (
<Link
key={entry.id}
href={href}
data-slot="dashboard-tile-link"
data-tile-id={entry.id}
className="group focus-visible:ring-ring/50 hover:ring-ring/30 flex min-w-0 rounded-xl transition-[box-shadow] hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
>
{cell}
</Link>
) : (
<div key={entry.id} className="flex min-w-0">
{cell}
</div>
);
})}
</div>
)}
{chartEntries.map((entry) => (
<div key={entry.id} className="space-y-2">
{/* A boundary per chart, so each paints on its own instead of the
row waiting for the slowest. A gated cell keeps its chart
mounted (its query fires) but shows the skeleton until the
shared reveal flips; the trend hint rides inside the cell so it
appears with its chart. */}
{entry.revealGated ? (
<DashboardChartCell revealed={chartsRevealed}>
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
{entry.count != null ? <TrendHint count={entry.count} /> : null}
</DashboardChartCell>
) : (
<>
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
{entry.count != null ? <TrendHint count={entry.count} /> : null}
</>
)}
</div>
))}
{chartRowPlaceholderCount > 0 && (
<div
aria-hidden="true"
data-slot="dashboard-chart-row-skeleton"
className="space-y-6"
>
{Array.from({ length: chartRowPlaceholderCount }).map((_, idx) => (
<ChartSkeleton key={`chart-row-skeleton-${idx}`} />
))}
</div>
)}
</>
);
}
@@ -0,0 +1,497 @@
"use client";
import type { ReactNode } from "react";
import {
Activity,
Bone,
Droplet,
Droplets,
Dumbbell,
Footprints,
Gauge,
GlassWater,
Heart,
HeartPulse,
Moon,
Percent,
Smile,
Target,
Thermometer,
TrendingUp,
Wind,
} from "lucide-react";
import { TrendCard } from "@/components/charts/trend-card";
import {
getRangeColorClass,
getRangeHint,
toHoursSummary,
} from "@/components/dashboard/range-display";
import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker";
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
import { convertGlucose, glucoseFractionDigits } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
import type { DashboardTileContext } from "./tile-context";
/** One card in the tile strip, positioned by `order`. */
export interface StripEntry {
id: string;
order: number;
node: ReactNode;
}
/**
* A strip tile: which layout widget switches and orders it, the rule that
* decides whether it has anything to show, and the card(s) it paints.
*
* The layout toggle is applied by `buildStripEntries` for every tile, so
* `visible` holds only the tile's own rule (a data floor, a module gate).
* A tile can paint more than one card (blood pressure paints two, glucose one
* per meal-time context); each card's `orderOffset` keeps them together
* behind the widget's position.
*/
interface StripTile {
widget: string;
visible: (ctx: DashboardTileContext) => boolean;
cards: (
ctx: DashboardTileContext,
) => Array<{ id: string; orderOffset?: number; node: ReactNode }>;
}
const countOf = (summary: { count?: number } | null | undefined) =>
summary?.count ?? 0;
/**
* The pulse tile shows ONE of two statistics, and its name says which. With
* resting rows it is the resting heart rate (one figure a day, judged against
* the resting band); without them it is the pulse, whose latest value is the
* latest day's mean of its hours' means, and no resting band colours it (it
* would mark workout heart rate as "outside target").
*/
function pulseSource(ctx: DashboardTileContext) {
const rhr = ctx.summaries?.RESTING_HEART_RATE;
const hasRestingHr = countOf(rhr) > 0;
return {
hasRestingHr,
summary: hasRestingHr ? rhr : ctx.summaries?.PULSE,
type: hasRestingHr ? "RESTING_HEART_RATE" : "PULSE",
};
}
type Summary = NonNullable<DashboardTileContext["summaries"]>[string];
type TrafficRange = Parameters<typeof getRangeColorClass>[1]["range"];
/**
* One metric tile: the headline, its 7- and 30-day means, the trend and the
* comparison delta. `type` names the series (direction and freshness follow
* it unless overridden); `summary` replaces the summary read off `type`.
* With `display` the values go through the metric / imperial transform, and
* with `range` the means are coloured against it (on the canonical values)
* and carry its hint (in `hintRange`, the display unit, when given).
*/
function metricCard(
ctx: DashboardTileContext,
id: string,
props: {
type: string;
label: string;
unit: string;
icon: React.ComponentType<{ className?: string }>;
summary?: Summary | null;
display?: boolean;
fractionDigits?: number;
range?: TrafficRange;
hintRange?: TrafficRange;
direction?: TrendDirectionSentiment;
staleType?: string | null;
valueAdornment?: ReactNode;
},
): ReactNode {
const s = "summary" in props ? props.summary : ctx.summaries?.[props.type];
const value = (v: number | null | undefined) =>
props.display ? ctx.td(props.type, v) : (v ?? null);
const delta = (v: number | null | undefined) =>
props.display ? ctx.tdd(props.type, v) : v;
const hint =
props.range === undefined
? undefined
: getRangeHint(
props.unit,
{ range: props.hintRange ?? props.range },
ctx.t,
ctx.fmt.number,
);
return (
<TrendCard
key={id}
fractionDigits={props.fractionDigits}
label={props.label}
latest={value(s?.latest)}
unit={props.unit}
avg7={value(s?.avg7)}
avg30={value(s?.avg30)}
avg7ColorClass={
props.range === undefined
? undefined
: getRangeColorClass(s?.avg7, { range: props.range })
}
avg30ColorClass={
props.range === undefined
? undefined
: getRangeColorClass(s?.avg30, { range: props.range })
}
avg7Hint={hint}
avg30Hint={hint}
slope30={s?.slope30 ?? null}
trend7Delta={delta(summaryToTrend7Delta(s)) as number | null}
icon={props.icon}
directionSentiment={props.direction ?? ctx.directionOf(props.type)}
compareBaseline={ctx.compareBaseline}
compareDelta={delta(ctx.tileCompareDelta(s)) as number | null}
staleDays={ctx.tileStaleDays(
props.staleType === undefined ? props.type : props.staleType,
)}
valueAdornment={props.valueAdornment}
/>
);
}
/** A tile that paints one card under its own id. */
function one(
widget: string,
visible: StripTile["visible"],
node: (ctx: DashboardTileContext) => ReactNode,
): StripTile {
return { widget, visible, cards: (ctx) => [{ id: widget, node: node(ctx) }] };
}
/** A plain metric tile, shown when its series has a reading. */
function metricTile(
widget: string,
type: string,
props: (
ctx: DashboardTileContext,
) => Omit<Parameters<typeof metricCard>[2], "type">,
): StripTile {
return one(
widget,
(ctx) => countOf(ctx.summaries?.[type]) > 0,
(ctx) => metricCard(ctx, widget, { type, ...props(ctx) }),
);
}
/**
* Every strip tile, in the order the cards are collected. Cards are then
* sorted by their layout order (a stable sort), so for two widgets that share
* an order this list decides which comes first.
*/
export const STRIP_TILES: readonly StripTile[] = [
metricTile("weight", "WEIGHT", (ctx) => ({
label: ctx.t("dashboard.weightShort"),
unit: ctx.unitDisplay.unitFor("WEIGHT"),
icon: Activity,
display: true,
range: ctx.weightRange,
hintRange: ctx.weightHintRange,
// Which way is progress depends on the person's own target, resolved
// on the server.
direction: ctx.weightTrend.direction,
})),
{
// One signal, two tiles: systolic and diastolic side by side at the size
// of every other tile. The diastolic card's offset keeps it right behind
// the systolic one.
widget: "bp",
visible: (ctx) =>
countOf(ctx.summaries?.BLOOD_PRESSURE_SYS) > 0 ||
countOf(ctx.summaries?.BLOOD_PRESSURE_DIA) > 0,
cards: (ctx) => [
{
id: "bp-sys",
node: metricCard(ctx, "bp-sys", {
type: "BLOOD_PRESSURE_SYS",
label: ctx.t("dashboard.bloodPressureSysShort"),
unit: "mmHg",
icon: Heart,
fractionDigits: 0,
range: ctx.bpSysRange,
}),
},
{
id: "bp-dia",
orderOffset: 0.001,
node: metricCard(ctx, "bp-dia", {
type: "BLOOD_PRESSURE_DIA",
label: ctx.t("dashboard.bloodPressureDiaShort"),
unit: "mmHg",
icon: Heart,
fractionDigits: 0,
range: ctx.bpDiaRange,
}),
},
],
},
one(
"pulse",
(ctx) =>
countOf(ctx.summaries?.PULSE) > 0 ||
countOf(ctx.summaries?.RESTING_HEART_RATE) > 0,
(ctx) => {
const { hasRestingHr, summary, type } = pulseSource(ctx);
return metricCard(ctx, "pulse", {
type,
summary,
label: hasRestingHr
? ctx.t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE)
: ctx.t("dashboard.pulseShort"),
unit: "bpm",
icon: TrendingUp,
fractionDigits: 0,
range: hasRestingHr ? ctx.pulseDisplayRange : null,
});
},
),
metricTile("bodyFat", "BODY_FAT", (ctx) => ({
label: ctx.t("dashboard.bodyFatShort"),
unit: "%",
icon: Percent,
})),
one(
"mood",
(ctx) => countOf(ctx.moodSummary) > 0,
(ctx) =>
metricCard(ctx, "mood", {
type: "MOOD",
summary: ctx.moodSummary,
label: ctx.t("dashboard.moodShort"),
unit: "/ 5",
icon: Smile,
staleType: null,
}),
),
one(
"sleep",
(ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0,
(ctx) => {
// The summary carries per-night time asleep in MINUTES; the tile
// reads hours, so every value field converts while the staleness and
// count metadata stay as they are.
const raw = ctx.summaries?.SLEEP_DURATION;
const discrepancy = ctx.data?.sleepSourceDiscrepancy;
return metricCard(ctx, "sleep", {
type: "SLEEP_DURATION",
summary: raw ? toHoursSummary(raw) : undefined,
label: ctx.t("dashboard.sleepShort") ?? "Sleep",
unit: "h",
icon: Moon,
// A discreet "sources disagree" marker beside the headline when two
// writers reported clearly different totals for the latest night.
// Null on the ordinary path: no layout change.
valueAdornment: discrepancy ? (
<SleepSourceDiscrepancyMarker discrepancy={discrepancy} />
) : null,
});
},
),
// Steps are activity data tied to the workouts module; only an explicit
// `false` hides them (fail-open).
one(
"steps",
(ctx) =>
countOf(ctx.summaries?.ACTIVITY_STEPS) > 0 &&
ctx.user?.modules?.workouts !== false,
(ctx) =>
metricCard(ctx, "steps", {
type: "ACTIVITY_STEPS",
label: ctx.t("dashboard.stepsShort") ?? "Steps",
unit: "",
icon: Footprints,
fractionDigits: 0,
}),
),
metricTile("vo2Max", "VO2_MAX", (ctx) => ({
label: ctx.t("dashboard.vo2MaxShort") ?? "VO₂ max",
unit: ctx.t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)",
icon: Gauge,
})),
// HRV is stored under two types (SDNN from Apple Health / Health Connect,
// nightly RMSSD from rings and straps); `pickHrvSummary` names the one
// shown, and the label, the direction and the freshness caption follow it.
// Each series has its OWN label key, never a base label with the measure
// appended: the label renders truncated on one line, and an appended
// marker is the first thing a longer locale drops.
one(
"hrv",
(ctx) => countOf(ctx.hrv.summary) > 0,
(ctx) =>
metricCard(ctx, "hrv", {
type: ctx.hrv.type,
summary: ctx.hrv.summary,
label: ctx.t(ctx.hrv.labelKey),
unit: "ms",
icon: HeartPulse,
fractionDigits: 0,
}),
),
metricTile("oxygenSaturation", "OXYGEN_SATURATION", (ctx) => ({
label: ctx.t("measurements.typeOxygenSaturation"),
unit: "%",
icon: Droplets,
})),
metricTile("respiratoryRate", "RESPIRATORY_RATE", (ctx) => ({
label: ctx.t("measurements.typeRespiratoryRate"),
unit: ctx.t("insights.units.respiratoryRate"),
icon: Wind,
})),
metricTile("wristTemperature", "WRIST_TEMPERATURE", (ctx) => ({
label: ctx.t("measurements.typeWristTemperature"),
unit: ctx.unitDisplay.unitFor("WRIST_TEMPERATURE"),
icon: Thermometer,
display: true,
})),
metricTile("muscleMass", "MUSCLE_MASS", (ctx) => ({
label: ctx.t("measurements.typeMuscleMass"),
unit: ctx.unitDisplay.unitFor("MUSCLE_MASS"),
icon: Dumbbell,
display: true,
})),
metricTile("totalBodyWater", "TOTAL_BODY_WATER", (ctx) => ({
label: ctx.t("measurements.typeTotalBodyWater"),
unit: ctx.unitDisplay.unitFor("TOTAL_BODY_WATER"),
icon: Droplet,
display: true,
})),
metricTile("boneMass", "BONE_MASS", (ctx) => ({
label: ctx.t("measurements.typeBoneMass"),
unit: ctx.unitDisplay.unitFor("BONE_MASS"),
icon: Bone,
display: true,
})),
// Fluid intake: a synthetic summary key derived server-side from the
// nutrient days. Neutral sentiment, since more water is not simply better.
metricTile("waterIntake", "NUTRIENT_WATER", (ctx) => ({
label: ctx.t("nutrients.names.water"),
unit: "mL",
icon: GlassWater,
})),
{
// Blood pressure in target. Hidden while every window reads zero, so a
// literal "0 %" never rides the dashboard; the headline is the trailing
// 90-day share, the label names the effective span until 90 days of
// history exist, and below the confidence floor the tile says it is
// still collecting instead of printing a figure.
widget: "bpInTarget",
visible: (ctx) =>
ctx.data?.bpInTargetPct != null &&
[
ctx.data?.bpInTargetPct,
ctx.data?.bpInTargetPct7d,
ctx.data?.bpInTargetPct30d,
ctx.data?.bpInTargetPctAllTime,
].some((pct) => pct != null && pct > 0),
cards: (ctx) => {
const data = ctx.data;
const bp7 = data?.bpInTargetPct7d ?? null;
const bp30 = data?.bpInTargetPct30d ?? null;
const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null;
const bpPriorYear = data?.bpInTargetPctPriorYear ?? null;
const bpTrendDelta = bp7 !== null && bp30 !== null ? bp7 - bp30 : null;
const bpComparePrior =
ctx.compareBaseline === "lastMonth"
? bpPriorMonth
: ctx.compareBaseline === "lastYear"
? bpPriorYear
: null;
const bpCompareDelta =
ctx.compareBaseline === "none" ||
bp30 === null ||
bpComparePrior === null
? null
: Math.round((bp30 - bpComparePrior) * 10) / 10;
const bpCount90 = data?.bpInTargetCount90 ?? null;
const bpSpanDays90 = data?.bpInTargetSpanDays90 ?? null;
const bpSufficient = bpCount90 === null || isWindowSufficient(bpCount90);
const bpLabel =
bpSpanDays90 != null
? ctx.t("dashboard.bpInTargetWindowDynamic", { days: bpSpanDays90 })
: ctx.t("dashboard.bpInTargetWindow90");
return [
{
id: "bpInTarget",
node: (
<TrendCard
key="bpInTarget"
label={bpLabel}
latest={bpSufficient ? (data?.bpInTargetPct ?? null) : null}
emptyHint={
bpSufficient ? null : ctx.t("dashboard.bpInTargetCollecting")
}
unit="%"
avg7={bpSufficient ? bp7 : null}
avg30={bpSufficient ? bp30 : null}
slope30={null}
trend7Delta={bpSufficient ? bpTrendDelta : null}
icon={Target}
directionSentiment={ctx.directionOf("BLOOD_PRESSURE_IN_TARGET")}
compareBaseline={ctx.compareBaseline}
compareDelta={bpSufficient ? bpCompareDelta : null}
/>
),
},
];
},
},
{
// One card per meal-time bucket, kept together in a block.
widget: "glucose",
visible: (ctx) => ctx.glucoseTiles.length > 0,
cards: (ctx) =>
ctx.glucoseTiles.map(({ bucket, labelKey, summary: s }, idx) => {
const unit = ctx.displayGlucoseUnit;
const convert = (v: number | null | undefined) =>
v != null ? convertGlucose(v, unit) : null;
return {
id: `glucose-${bucket}`,
orderOffset: idx / 1000,
node: (
<TrendCard
key={`glucose-${bucket}`}
label={ctx.t(labelKey)}
latest={convert(s.latest)}
unit={unit}
fractionDigits={glucoseFractionDigits(unit)}
avg7={convert(s.avg7)}
avg30={convert(s.avg30)}
slope30={s.slope30 ?? null}
icon={Droplet}
staleDays={ctx.tileStaleDays("BLOOD_GLUCOSE")}
/>
),
};
}),
},
];
/**
* The strip's cards for this render: every tile whose layout toggle is on and
* whose own rule passes, sorted by layout order.
*/
export function buildStripEntries(ctx: DashboardTileContext): StripEntry[] {
const entries: StripEntry[] = [];
for (const tile of STRIP_TILES) {
if (!ctx.isTileVisible(tile.widget) || !tile.visible(ctx)) continue;
const order = ctx.widgetOrder(tile.widget);
for (const card of tile.cards(ctx)) {
entries.push({
id: card.id,
order: order + (card.orderOffset ?? 0),
node: card.node,
});
}
}
entries.sort((a, b) => a.order - b.order);
return entries;
}
@@ -0,0 +1,267 @@
"use client";
import { useMemo } from "react";
import {
pickHrvSummary,
resolveGlucoseTiles,
} from "@/components/dashboard/dashboard-gates";
import type { AuthUser } from "@/hooks/use-auth";
import {
useUnitDisplayOnceMounted,
type UnitDisplay,
} from "@/hooks/use-unit-display";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import type { DataSummary } from "@/lib/analytics/trends";
import {
buildDashboardBands,
viewerBandProfile,
type DashboardTargetBands,
} from "@/lib/dashboard/bands";
import type { DashboardLayout } from "@/lib/dashboard-layout";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
import { toProfileSex } from "@/lib/profile/sex";
import {
resolveWeightTrend,
weightTrendReferenceKg,
} from "@/lib/targets/weight-trend";
import type { DashboardData } from "./use-dashboard-data";
/**
* The resolved bands in the shape the tiles and charts draw them: weight in
* the display unit, blood pressure as the chart's two target zones.
*/
function bandsForDisplay(
bands: DashboardTargetBands,
unitDisplay: UnitDisplay,
t: (key: string) => string,
) {
const weightRange = bands.weightRange;
// The range hint prints its bounds in the display unit, so the kg
// thresholds scale by the weight factor (offset-free). The colour classing
// stays canonical: it compares a canonical average against the canonical
// range.
const weightHintRange = weightRange
? {
greenMin: unitDisplay.toDisplay("WEIGHT", weightRange.greenMin),
greenMax: unitDisplay.toDisplay("WEIGHT", weightRange.greenMax),
orangeMin: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMin),
orangeMax: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMax),
}
: weightRange;
const weightBands = bands.weightBands ?? undefined;
// The kg chart bands scaled into the display unit so the shaded zone
// tracks the converted line (factor only; weight has no offset).
const weightDisplayBands =
weightBands && unitDisplay.preference === "imperial"
? weightBands.map((band) => ({
...band,
min: unitDisplay.toDisplay("WEIGHT", band.min),
max: unitDisplay.toDisplay("WEIGHT", band.max),
}))
: weightBands;
const bpTargets = bands.bpTargets;
const bpTargetZones = bpTargets
? [
{
min: bpTargets.sysLow,
max: bpTargets.sysHigh,
color: "var(--chart-3)",
opacity: 0.21,
label: t("charts.systolic"),
textColor: "var(--chart-3)",
lineOpacity: 0.24,
},
{
min: bpTargets.diaLow,
max: bpTargets.diaHigh,
color: "var(--chart-4)",
opacity: 0.21,
label: t("charts.diastolic"),
textColor: "var(--chart-4)",
lineOpacity: 0.24,
},
]
: undefined;
return {
weightRange,
weightHintRange,
weightDisplayBands,
bpTargetZones,
bpSysRange: bands.bpSysRange,
bpDiaRange: bands.bpDiaRange,
pulseDisplayRange: bands.pulseDisplayRange,
pulseBands: bands.pulseBands,
bodyFatBands: bands.bodyFatBands,
};
}
/**
* What every dashboard tile reads: the summaries, the resolved layout, the
* display units and the target bands, each derived once per render here so a
* tile definition stays a function of this object and nothing else.
*/
export function useDashboardTileContext({
dashboard,
user,
}: {
dashboard: DashboardData;
user: AuthUser | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
// Metric / imperial display choke point. `td` converts an ABSOLUTE
// reading (affine, so a temperature picks up the °F offset); `tdd`
// converts a DELTA or slope (factor only, so a Δ never inherits the
// offset). Metric users take the identity path.
const unitDisplay = useUnitDisplayOnceMounted();
const td = (type: string, value: number | null | undefined): number | null =>
value == null ? null : unitDisplay.toDisplay(type, value);
const tdd = (
type: string,
value: number | null | undefined,
): number | null =>
value == null ? null : unitDisplay.toDisplayDelta(type, value);
const { data, layout, layoutData, moodData, snapshot, snapshotEnabled } =
dashboard;
const summaries = data?.summaries;
const compareBaseline = layout.comparisonBaseline ?? "none";
/**
* The tile delta (current 30-day average minus the prior period's) for a
* summary. Null when comparison is off or either side is missing, so the
* tile suppresses the callout.
*/
const tileCompareDelta = (
summary: DataSummary | null | undefined,
): number | null => {
if (!summary || compareBaseline === "none") return null;
const current = summary.avg30 ?? null;
const prior =
compareBaseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
: (summary.avg30LastYear ?? null);
if (current === null || prior === null) return null;
return Math.round((current - prior) * 100) / 100;
};
/**
* Days since the metric's last reading when that is more than a week, for
* `<TrendCard staleDays>`; null for a fresh metric or one with no reading.
*/
const tileStaleDays = (type: string | null | undefined): number | null => {
if (!type) return null;
const entry = data?.lastSeenByType?.[type];
if (!entry) return null;
return entry.daysAgo > 7 ? entry.daysAgo : null;
};
/** Whether the widget's chart (lower row) shows. */
const isChartVisible = (id: string) =>
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
/**
* Whether the widget's tile in the strip shows. Independent of the chart,
* falling back to the chart flag for layouts saved before the two split.
*/
const isTileVisible = (id: string) => {
const widget = layout.widgets.find((w) => w.id === id);
if (!widget) return false;
return typeof widget.tileVisible === "boolean"
? widget.tileVisible
: widget.visible;
};
const widgetOrder = (id: string) =>
layout.widgets.find((widget) => widget.id === id)?.order ?? 999;
// `layoutData` is undefined until the real layout (snapshot or legacy
// widgets) resolves, and the fallback layout shows cards a person may have
// switched off. Cards gated on the layout alone wait for the real one.
const layoutResolved = layoutData != null;
// Band and target numbers come resolved from the snapshot (`targetBands`).
// The client copy below is computed from the profile through the SAME
// `buildDashboardBands` the server uses, so a still-loading snapshot frame
// keeps real bands rather than blank charts and the two cannot disagree.
// The profile it reads is the caller's, and under a switch the caller is
// the delegate: `viewerBandProfile` then draws no client band at all and
// waits for the snapshot's, which resolve against the record. Keyed on the
// `user` identity so the React Compiler can preserve the memo.
const inSharedRecord = user?.accountAccess?.active != null;
const thresholdsData = dashboard.thresholdsData;
const clientBandProfile = useMemo(
() =>
viewerBandProfile(
{
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
thresholdsJson: thresholdsData?.overrides ?? null,
},
inSharedRecord,
),
[user, thresholdsData, inSharedRecord],
);
const clientBands = useMemo(
() => buildDashboardBands(clientBandProfile),
[clientBandProfile],
);
const serverBands = snapshotEnabled ? snapshot?.targetBands : undefined;
const bands = serverBands ?? clientBands;
// Which way a weight change counts as progress against the stored target.
// The snapshot carries the server's answer; the fallback asks the same
// resolver from the same profile the band fallback reads.
const weight = summaries?.WEIGHT;
const weightTrend =
(snapshotEnabled ? snapshot?.tiles.weightTrend : undefined) ??
resolveWeightTrend(
resolveWeightTargetOverride(clientBandProfile.thresholdsJson),
weightTrendReferenceKg(weight),
);
// Every other tile's direction comes resolved from the server against the
// record's own targets. The snapshot-disabled path has no resolved answer
// and renders neutral rather than guessing a polarity.
const tileDirections = snapshotEnabled
? snapshot?.tiles.directions
: undefined;
const directionOf = (key: string): TrendDirectionSentiment =>
tileDirections?.[key]?.direction ?? "neutral";
// Glucose has no chart slot; the tile flag is its single source of truth.
// Eligibility walks the untagged bucket beside the four named contexts, so
// an account whose source never writes a meal-time tag still sees a tile.
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const glucoseTiles = resolveGlucoseTiles(data?.glucoseByContext ?? {});
return {
t,
fmt,
unitDisplay: unitDisplay as UnitDisplay,
td,
tdd,
user,
data,
summaries,
moodSummary: moodData?.summary,
hrv: pickHrvSummary(summaries),
layout: layout as DashboardLayout,
layoutResolved,
compareBaseline,
tileCompareDelta,
tileStaleDays,
isChartVisible,
isTileVisible,
widgetOrder,
directionOf,
weightTrend,
...bandsForDisplay(bands, unitDisplay, t),
displayGlucoseUnit,
glucoseTiles,
};
}
export type DashboardTileContext = ReturnType<typeof useDashboardTileContext>;
@@ -0,0 +1,288 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { AuthUser } from "@/hooks/use-auth";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { apiGet } from "@/lib/api/api-fetch";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import type { DataSummary } from "@/lib/analytics/trends";
import {
computeLocalBatchWindow,
deriveBatchChartTypes,
type BatchWindow,
} from "@/lib/dashboard/batch-chart-types";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { hideModuleWidgets } from "@/lib/dashboard/widget-modules";
import {
resolveDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDailyDigest } from "@/lib/queries/use-daily-digest";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { queryKeys } from "@/lib/query-keys";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
/**
* Module scope so the option object is a stable reference across renders.
*
* Refetch on window focus: the dashboard is the surface a person leaves open
* in a background tab while changing its tile selection elsewhere; without it
* the stale layout sat visible for up to the poll interval ("saved but nothing
* changed"). `staleTime` bounds the cost to one refetch a minute, and the
* server side is SWR-cached, so a focus flick is a cheap cache hit.
*/
const DASHBOARD_QUERY_OPTS = {
staleTime: 60_000,
refetchOnWindowFocus: true,
} as const;
/** One day of the batched series the dashboard charts read their slice from. */
export interface BatchedSeriesRow {
value: number;
measuredAt: string;
count?: number;
minValue?: number | null;
maxValue?: number | null;
}
export interface DashboardMoodData {
entries: Array<{ date: string; score: number; samples: number }>;
summary: DataSummary;
}
/**
* The tiles' `AnalyticsData` from whichever path feeds them. Snapshot path:
* the one cell, where `extras` is null on a rollup-coverage miss (two-phase
* contract), so the BP-in-target and glucose fields stay undefined and those
* tiles shimmer while the rest of the strip paints. Legacy path: the two
* analytics slices merged.
*/
function assembleDashboardAnalytics(
snapshotEnabled: boolean,
snapshot: ReturnType<typeof useDashboardSnapshot>["data"],
slim: Parameters<typeof mergeSlimAndThickAnalytics>[0],
thick: Parameters<typeof mergeSlimAndThickAnalytics>[1],
): AnalyticsData | undefined {
if (snapshotEnabled) {
const snap = snapshot;
if (!snap) return undefined;
return {
summaries: snap.tiles.summaries,
lastSeenByType: snap.tiles.lastSeenByType,
// Optional on the snapshot (additive contract).
sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null,
bpInTargetPct: snap.extras?.bpInTargetPct ?? null,
bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null,
bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null,
bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null,
bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null,
glucoseByContext: snap.extras?.glucoseByContext as
Record<string, DataSummary> | undefined,
};
}
// `mergeSlimAndThickAnalytics` falls back to the thick slice when the
// slim one resolves with no content, and otherwise lets slim paint first.
const merged = mergeSlimAndThickAnalytics(slim, thick);
if (!merged) return undefined;
return {
summaries: merged.summaries,
lastSeenByType: merged.lastSeenByType,
sleepSourceDiscrepancy: merged.sleepSourceDiscrepancy,
bpInTargetPct: merged.bpInTargetPct,
bpInTargetPct7d: merged.bpInTargetPct7d,
bpInTargetPct30d: merged.bpInTargetPct30d,
bpInTargetPctAllTime: merged.bpInTargetPctAllTime,
bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth,
bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear,
bpInTargetCount90: merged.bpInTargetCount90,
bpInTargetSpanDays90: merged.bpInTargetSpanDays90,
glucoseByContext: merged.glucoseByContext as
Record<string, DataSummary> | undefined,
};
}
/**
* Every read the dashboard's tiles and charts paint from.
*
* The tiles hydrate from ONE un-gated `/api/dashboard/snapshot` cell, so the
* whole strip shares one completion moment and `/api/auth/me` leaves the cold
* critical path. `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` falls back to the
* legacy independent cells (two analytics slices, the widget layout, the mood
* analytics and the user's own thresholds); either way the same
* `AnalyticsData` shape comes out, so no tile knows which path fed it.
*
* The Today hero's digest is read here too, in its original place in the read
* order: it is the day's data, so no module owns it, and it is gated on auth
* only.
*/
export function useDashboardData({
isAuthenticated,
mounted,
user,
batchWindowProp,
}: {
isAuthenticated: boolean;
mounted: boolean;
user: AuthUser | null;
/**
* The batched-series window the RSC prefetch computed from the profile
* time zone. Adopted verbatim so the `chartSeriesBatch` key matches the key
* the server dehydrated under; absent on the prefetch-off and legacy paths,
* where the browser-local window stands in.
*/
batchWindowProp?: BatchWindow;
}) {
const snapshotEnabled = isDashboardSnapshotEnabled();
const snapshotQuery = useDashboardSnapshot(snapshotEnabled);
const digestQuery = useDailyDigest(isAuthenticated);
// Legacy path: the slim slice (`?slice=summaries`) paints every per-type
// tile; the thick slice streams the BP-in-target and glucose fields in
// afterwards. Both share `caches.analytics` server-side.
const analyticsSlimQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !snapshotEnabled && isAuthenticated,
});
const analyticsThickQuery = useAnalyticsQuery({
enabled: !snapshotEnabled && isAuthenticated,
});
const data = useMemo(
() =>
assembleDashboardAnalytics(
snapshotEnabled,
snapshotQuery.data,
analyticsSlimQuery.data,
analyticsThickQuery.data,
),
[
snapshotEnabled,
snapshotQuery.data,
analyticsSlimQuery.data,
analyticsThickQuery.data,
],
);
// The user's own targets, for the legacy (snapshot-disabled) band fallback
// only. In snapshot mode the server resolves the same overrides into
// `targetBands` and this query never fires.
const { data: thresholdsData } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet<{
overrides: Record<string, { min: number; max: number }>;
}>("/api/user/thresholds");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
const { data: layoutDataLegacy } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
return apiGet<DashboardLayout>("/api/dashboard/widgets");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
// The snapshot publishes its layout with switched-off modules' widgets
// already hidden. The legacy feed is the stored layout as saved (Settings
// edits that exact value), so the same mask is applied here, from the same
// surface map, before anything paints.
const layoutData = snapshotEnabled
? snapshotQuery.data?.layout
: layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules);
const moodWidgetVisible = useSurfaceVisible("widget:mood");
const { data: moodDataLegacy } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async () => {
return apiGet<DashboardMoodData>("/api/mood/analytics");
},
// The route refuses with the mood module off; do not ask.
enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible,
...DASHBOARD_QUERY_OPTS,
});
const moodData: DashboardMoodData | undefined = snapshotEnabled
? snapshotQuery.data
? {
entries: snapshotQuery.data.tiles.mood.entries,
summary: (snapshotQuery.data.tiles.mood.summary ??
undefined) as DataSummary,
}
: undefined
: moodDataLegacy;
// Visibility and order of every widget.
const layout = resolveDashboardLayout(layoutData);
// ONE batched fetch for every visible non-sleep chart series; each chart
// reads its slice through `preloadedSeries` instead of self-fetching. Sleep
// stays on its per-night `/series` adapter and is never batched. The type
// set is the shared `deriveBatchChartTypes`, the same function the RSC
// prefetch runs over the same snapshot fields, so the cache key is
// byte-identical on both sides.
const batchChartTypes = deriveBatchChartTypes(layout, data?.summaries);
// A day-bucketed window, computed once per mount (lazy state) so a
// re-render never re-keys the batch. The server-computed window wins when
// present; lazy state with a prop-derived initial value is deterministic
// across the SSR / CSR pair.
const [batchWindow] = useState(
() => batchWindowProp ?? computeLocalBatchWindow(),
);
const { data: batchedSeries } = useQuery({
queryKey: queryKeys.chartSeriesBatch(
batchChartTypes.join(","),
batchWindow.from,
batchWindow.to,
),
enabled: isAuthenticated && mounted && batchChartTypes.length > 0,
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
queryFn: async () => {
const sp = new URLSearchParams();
sp.set("types", batchChartTypes.join(","));
sp.set("from", batchWindow.from);
sp.set("to", batchWindow.to);
const res = await apiGet<{
series: Record<string, BatchedSeriesRow[]>;
}>(`/api/measurements/series-batch?${sp}`);
return res.series;
},
});
// The loading flag follows whichever query actually drives the tiles: in
// snapshot mode the slim query is disabled and reports `isLoading: false`
// forever, which would select the empty state for the whole snapshot
// fetch. `!mounted` pins the SSR pass and the hydration render to the
// skeleton branch (server-side the data queries are disabled, and a late
// hydrating boundary would otherwise render the skeleton where the server
// rendered the empty state: React #418). The hero band and the strip share
// this one gate so both swap to content in the same render pass.
const primaryLoading =
!mounted ||
(snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading);
return {
snapshotEnabled,
snapshot: snapshotQuery.data,
digestQuery,
data,
thresholdsData,
layoutData,
layout,
moodData,
preloadedSeries: batchedSeries,
primaryLoading,
};
}
export type DashboardData = ReturnType<typeof useDashboardData>;
@@ -131,10 +131,32 @@ export function stripComments(text: string): string {
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
}
const source = new Map(
const read = new Map(
files.map((f) => [rel(f), stripComments(readFileSync(f, "utf8"))]),
);
/**
* A component and the folder of the same name beside it are one chart: the
* HealthChart composer in `health-chart.tsx` draws the axes, and its layers,
* tooltip and day footer live in `health-chart/`. Read apart, the composer
* would look like a date-axis chart with no doors and the folder like no
* chart at all, so each folder is folded into its component's entry.
*/
export function foldComponentFolders(
sources: Map<string, string>,
): Map<string, string> {
const folded = new Map<string, string>();
const owners = [...sources.keys()].filter((f) => f.endsWith(".tsx"));
for (const [file, text] of sources) {
const owner = owners.find((o) => file.startsWith(`${o.slice(0, -4)}/`));
const key = owner ?? file;
folded.set(key, `${folded.get(key) ?? ""}\n${text}`);
}
return folded;
}
const source = foldComponentFolders(read);
/**
* The text of every JSX opening tag named `name` in `text`: from `<Name` to
* the `>` that closes it at brace depth zero, so an arrow function or a
@@ -223,7 +245,10 @@ function findMissingDoors(): {
/\bChartDayFooter\b/.test(text) &&
/\bOPEN_DAY_LINE\b/.test(text) &&
/\.tooltipProps\b/.test(text) &&
/\.tooltipAction\(/.test(text);
// Called in place, or handed whole to a tooltip that calls it.
/\.tooltipAction\(|\btooltipAction:\s*\w+\.tooltipAction\b/.test(
text,
);
if (wired) {
doors.charts += 1;
doors.chartFiles.push(file);
@@ -263,9 +288,10 @@ describe("every chart drawn in days opens its days, or says why not", () => {
it("still finds the doors it is meant to find (an empty match set fails)", () => {
// HealthChart on the metric pages (two mounts in the HealthKit page),
// blood pressure, weight, pulse, BMI, recovery, sleep, the mood line,
// water and caffeine; on the dashboard its seven charts, the mood line
// and the adherence chart.
expect(doors.mounts).toBeGreaterThanOrEqual(20);
// water and caffeine; on the dashboard the one mount its chart builder
// draws all seven metric charts through, the mood line and the
// adherence chart.
expect(doors.mounts).toBeGreaterThanOrEqual(14);
// HealthChart, the mood line and the nutrient bars themselves, sleep
// stages, mood dimensions, lab marker, custom metric, questionnaire,
// basal temperature, dose strength, efficacy, the adherence chart:
@@ -303,6 +329,29 @@ describe("every chart drawn in days opens its days, or says why not", () => {
}
});
it("folds a component's same-named folder into it, and nothing else", () => {
const folded = foldComponentFolders(
new Map([
["src/components/charts/health-chart.tsx", "<XAxis />"],
[
"src/components/charts/health-chart/chart-layers.tsx",
"ChartDayFooter",
],
["src/components/charts/health-chart-dynamic.tsx", "dynamic"],
]),
);
expect([...folded.keys()].sort()).toEqual([
"src/components/charts/health-chart-dynamic.tsx",
"src/components/charts/health-chart.tsx",
]);
expect(folded.get("src/components/charts/health-chart.tsx")).toContain(
"ChartDayFooter",
);
expect(source.get("src/components/charts/health-chart.tsx")).toMatch(
/\buseChartDayLinks\(/,
);
});
it("reads a JSX tag to its real end, past arrow functions in its props", () => {
const [tag] = jsxTags(
`<HealthChart onVisibleStats={(s) => s > 1} dayLinks />`,
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { documentDetailSource } from "@/__tests__/helpers/document-detail-source";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -15,10 +16,6 @@ import { join } from "node:path";
* stays connected end-to-end.
*/
const ROOT = join(__dirname, "../../../..");
const DETAIL_SHEET = join(
ROOT,
"src/components/documents/document-detail-sheet.tsx",
);
const MOUNT = join(ROOT, "src/components/insights/layout-coach-mount.tsx");
const DRAWER = join(
ROOT,
@@ -30,7 +27,7 @@ function load(path: string): string {
}
describe("document detail sheet — Ask the Coach opens the drawer", () => {
const src = load(DETAIL_SHEET);
const src = documentDetailSource();
it("launches the doc-scoped coach drawer via askCoach(..., doc.id)", () => {
expect(src).toContain("useCoachLaunch");
@@ -21,13 +21,10 @@
* disabled state is real. A render could not see either.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SHEET = readFileSync(
join(process.cwd(), "src/components/documents/document-detail-sheet.tsx"),
"utf8",
);
import { documentDetailSource } from "@/__tests__/helpers/document-detail-source";
const SHEET = documentDetailSource();
describe("the detail sheet's original-document transport", () => {
it("has exactly one eager loader for the original", () => {
@@ -16,9 +16,15 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { documentDetailSource } from "@/__tests__/helpers/document-detail-source";
const dir = join(process.cwd(), "src/components/documents");
const view = readFileSync(join(dir, "documents-view.tsx"), "utf8");
const sheet = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8");
// The page and the selection hook it takes its delete path from.
const view = [
readFileSync(join(dir, "documents-view.tsx"), "utf8"),
readFileSync(join(dir, "vault/use-vault-selection.ts"), "utf8"),
].join("\n");
const sheet = documentDetailSource();
const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8");
describe("vault delete paths confirm before deleting", () => {
@@ -8,7 +8,7 @@ import {
documentSelectionHref,
documentSelectionSurvivedClose,
withoutDocumentSelectionHref,
} from "../documents-view";
} from "../vault/selection-history";
class MemoryHistory {
private entries: { href: string; state: unknown }[];
@@ -0,0 +1,409 @@
"use client";
import Link from "next/link";
import {
ArrowRight,
Check,
FlaskConical,
Pencil,
Plus,
Share2,
Sparkles,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { INBOUND_DOCUMENT_KINDS } from "@/lib/documents/inbound-constants";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { cn } from "@/lib/utils";
import type {
InboundDocumentDetailDto,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import {
DocumentDownloadButton,
DocumentOpenInTabButton,
} from "./original-file";
import type { DocumentDetail } from "./use-document-detail";
/**
* The sheet's footer. Delete sits bottom-left as a quiet text button (the
* bottom-right slot reads as the confirm action, and a destructive control
* there is a footgun); Coach, Share, open in a new tab and Download keep the
* trailing edge, icon-only on phones so they never overflow at 360 px.
*/
export function DocumentDetailFooter({
doc,
detail,
aiEnabled,
coachAvailable,
onOpenChange,
}: {
doc: InboundDocumentDetailDto;
detail: DocumentDetail;
aiEnabled: boolean;
coachAvailable: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
// The vault's "Ask the Coach" opens the fenced conversation scoped to this
// document in the shared side drawer.
const launch = useCoachLaunch();
const {
canManageDocuments,
setConfirmDeleteOpen,
remove,
setShareOpen,
inlineSrc,
inlineFailed,
} = detail;
return (
<div className="flex w-full items-center justify-between gap-2">
{/* .x — deleting, sharing and re-filing a document are the
owner's alone. Inside somebody else's record the sheet reads
the document and offers the download, and the leading slot
simply has nothing in it. */}
{canManageDocuments ? (
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setConfirmDeleteOpen(true)}
disabled={remove.isPending}
data-slot="document-detail-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{/* .x — `launch` is the one entry point that rendered
without asking whether a drawer exists to open. It is null
inside somebody else's record (the shell mounts no drawer
there), and `/documents` is a shared-record destination, so
this is the Coach button a delegate actually meets. */}
{aiEnabled && coachAvailable && launch ? (
// "Ask the Coach" opens the REAL
// coach conversation scoped to this document in the SIDE
// DRAWER (the maximize control there expands it to the full
// `/coach` page). Every doc turn still runs on the hardened
// fenced document endpoint (never the coach tool route) — only
// WHERE the conversation renders changed, not the send path.
// Close the detail sheet so the drawer owns the surface.
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
launch.askCoach(null, undefined, false, doc.id);
}}
data-slot="document-chat-open"
>
<Sparkles className="size-4" aria-hidden />
<span className="sr-only sm:not-sr-only">
{t("documents.chat.openButton")}
</span>
</Button>
) : null}
{canManageDocuments && (
<Button
variant="outline"
onClick={() => setShareOpen(true)}
data-slot="document-share"
aria-label={t("documents.detail.share")}
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.share")}
</span>
</Button>
)}
{doc.servingClass === "inline" ? (
<DocumentOpenInTabButton
readyUrl={inlineSrc}
unavailable={inlineFailed}
/>
) : null}
<DocumentDownloadButton
documentId={doc.id}
filename={doc.filename}
readyUrl={inlineSrc}
unavailable={inlineFailed}
variant="outline"
slot="document-download"
/>
</div>
</div>
);
}
/** Title, kind, filing date and the condition links, each saving on commit. */
export function DocumentDetailFields({
doc,
detail,
}: {
doc: InboundDocumentDetailDto;
detail: DocumentDetail;
}) {
const { t } = useTranslations();
const {
canManageDocuments,
editingTitle,
setEditingTitle,
titleDraft,
setTitleDraft,
commitTitle,
patch,
episodes,
toggleEpisode,
} = detail;
return (
<>
<div className="space-y-1.5">
<Label htmlFor="document-title-input">
{t("documents.detail.titleLabel")}
</Label>
{editingTitle ? (
<Input
id="document-title-input"
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") commitTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : !canManageDocuments ? (
<p
id="document-title-input"
className={cn(
"min-h-10 px-3 py-2 text-sm",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</p>
) : (
<button
type="button"
id="document-title-input"
onClick={() => {
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:border-input-focus flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:outline-none"
>
<span
className={cn(
"truncate",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</span>
<Pencil
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden
/>
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
{/* Read-only rather than absent: the value IS the content
a delegate came to read, and a form field that shows it
without accepting a change is the honest rendering. */}
<Select
value={doc.kind}
disabled={!canManageDocuments}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
>
<SelectTrigger id="document-kind-select" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOUND_DOCUMENT_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`documents.kind.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-date-field">
{t("documents.detail.dateLabel")}
</Label>
<DateField
id="document-date-field"
disabled={!canManageDocuments}
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({
documentDate: value === "" ? null : value,
})
}
aria-label={t("documents.detail.dateLabel")}
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-sm leading-none font-medium">
{t("documents.detail.conditionsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.conditionLinks.map((link) => (
<Link
key={link.episodeId}
href={`/illness/${link.episodeId}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">{link.name}</span>
</Link>
))}
{doc.conditionLinks.length === 0 ? (
<span className="text-muted-foreground text-xs">
{t("documents.detail.noConditions")}
</span>
) : null}
{canManageDocuments && (episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full px-2.5 text-xs"
>
<Plus className="size-3" aria-hidden />
{t("documents.detail.linkCondition")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
{episodes.data?.map((episode) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episode.id,
);
return (
<li key={episode.id}>
<button
type="button"
onClick={() => toggleEpisode(episode.id)}
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
aria-pressed={linked}
>
<Check
className={cn(
"size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="truncate">{episode.label}</span>
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
) : null}
</div>
</div>
</>
);
}
/**
* The lab values read from this page, when Labs holds any, and the upload,
* filename and import provenance meta lines.
*/
export function DocumentDetailMeta({
doc,
detail,
}: {
doc: InboundDocumentDetailDto;
detail: DocumentDetail;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
const { labsModuleEnabled, labFactCount, sourceSystemName } = detail;
return (
<>
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
data-slot="document-detail-lab-values-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 self-start rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FlaskConical className="size-4" aria-hidden="true" />
{t("documents.detail.labValuesLink", { count: labFactCount })}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
</p>
{doc.filename ? (
<p
className="text-muted-foreground truncate text-xs"
title={doc.filename}
>
{doc.filename}
</p>
) : null}
{/* Where an imported document came from (#1038). Meta, so
muted like the line above; the id is what the person looks
up in the other system. */}
{doc.sourceSystem ? (
<p
className="text-muted-foreground text-xs break-all"
data-slot="document-detail-provenance"
>
{doc.sourceId
? t("documents.detail.importedFromWithId", {
system: sourceSystemName(doc.sourceSystem),
id: doc.sourceId,
})
: t("documents.detail.importedFrom", {
system: sourceSystemName(doc.sourceSystem),
})}
</p>
) : null}
</>
);
}
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Download, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { createFencedBlobLoader } from "@/hooks/use-fenced-object-url";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* Download the stored original.
*
* A real button, not an anchor wearing `aria-disabled`.
*
* Two things were wrong with the anchor. `aria-disabled` tells a screen reader
* the control is unavailable and stops nothing: `href="#"` is still activated
* by a click and by Enter, so a person got a jump to the top of the page
* instead of a file. And it ignored the failure flag entirely, so a document
* whose original could not be fetched left a control that looked fine and
* silently did nothing, for good.
*
* It also fetches at CLICK time unless the bytes are already in hand. The
* sheet loads the original eagerly only for documents it is going to render
* inline; for everything else — a large scan, a download-only format — nothing
* is fetched or decrypted until somebody asks for it, which is what the plain
* `href` used to cost and what the first fix accidentally gave up.
*/
export function DocumentDownloadButton({
documentId,
filename,
readyUrl,
unavailable,
slot,
...buttonProps
}: {
documentId: string;
filename: string | null;
/** Already-fetched bytes to reuse (the inline preview's), or null. */
readyUrl: string | null;
/** A prior fetch of these bytes failed for good. */
unavailable: boolean;
slot: string;
} & React.ComponentProps<typeof Button>) {
const { t } = useTranslations();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const loaderRef = useRef<ReturnType<typeof createFencedBlobLoader> | null>(
null,
);
useEffect(() => () => loaderRef.current?.dispose(), []);
const save = (url: string) => {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename ?? "document";
anchor.click();
};
const onClick = async () => {
if (readyUrl) {
save(readyUrl);
return;
}
setPending(true);
setFailed(false);
// One loader for the lifetime of the button: loading again revokes the
// previous object URL, and unmounting revokes the live one. Without that a
// decrypted health document stays in the tab's memory until it closes.
loaderRef.current ??= createFencedBlobLoader();
const result = await loaderRef.current.load(
`/api/documents/inbound/${documentId}/original`,
);
setPending(false);
if (result.kind === "loaded") save(result.url);
else if (result.kind === "failed") setFailed(true);
};
const blocked = unavailable || failed;
return (
<Button
{...buttonProps}
data-slot={slot}
disabled={blocked || pending}
aria-label={t("documents.detail.download")}
title={blocked ? t("documents.detail.loadError") : undefined}
onClick={() => void onClick()}
>
<Download className="size-4" aria-hidden />
<span className="hidden sm:inline">
{blocked
? t("documents.detail.loadError")
: t("documents.detail.download")}
</span>
</Button>
);
}
/**
* Open the stored original in a new browser tab, for the types the browser
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
* long report gets the browser's own zoom and search.
*
* It opens the object URL the sheet already holds rather than the route: a
* plain navigation carries no record-session assertion, so the fence would
* refuse it once the session has been inside a shared record. The bytes are
* already in hand, so the tab opens inside the click and no popup blocker
* steps in. A type outside the inline class never gets the control.
*/
export function DocumentOpenInTabButton({
readyUrl,
unavailable,
}: {
readyUrl: string | null;
unavailable: boolean;
}) {
const { t } = useTranslations();
return (
<Button
variant="outline"
data-slot="document-open-in-tab"
disabled={readyUrl === null || unavailable}
aria-label={t("documents.detail.openInNewTab")}
onClick={() => {
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
}}
>
<ExternalLink className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.openInNewTab")}
</span>
</Button>
);
}
/** Inline preview for Class A documents; skeleton until the blob paints. */
export function InlinePreview({
src,
failed,
mimeType,
title,
}: {
/**
* The object URL, fetched ONCE by the sheet and handed down.
*
* The bytes come through the app transport because it carries the
* record-session assertion and a browser-issued `src="/api/…"` cannot. What
* changed since: this component used to fetch them itself while the sheet
* fetched the SAME original again for the download link, so opening a
* document decrypted and held two copies of it — and a large scan is a large
* copy. One loader now, lifted to the sheet.
*/
src: string | null;
failed: boolean;
mimeType: string;
title: string;
}) {
const [loaded, setLoaded] = useState(false);
const isPdf = mimeType === "application/pdf";
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
// frame's `load` event — stranding keyboard events inside the embedded
// document, so Escape stops closing the sheet the moment a PDF preview
// opens. While the guard window is open (armed at mount, extended at
// load, ended early by a deliberate pointer interaction with the
// preview), any focus landing on the frame bounces straight back to the
// dialog container; a user who clicks into the PDF is left alone.
const frameRef = useRef<HTMLIFrameElement | null>(null);
const stealGuardUntil = useRef(0);
const reclaimFocus = (frame: HTMLIFrameElement) => {
if (
Date.now() < stealGuardUntil.current &&
document.activeElement === frame
) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
};
useEffect(() => {
// Arm at mount, then run a frame-by-frame sweep for the steal variants
// that never dispatch a focus event on the frame element.
// Self-terminates with the guard.
stealGuardUntil.current = Date.now() + 3_000;
let raf = 0;
const sweep = () => {
const frame = frameRef.current;
if (!frame || Date.now() >= stealGuardUntil.current) return;
if (document.activeElement === frame) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
raf = requestAnimationFrame(sweep);
};
raf = requestAnimationFrame(sweep);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
{failed ? (
// The preview could not be fetched — refused, offline, or still
// rendering server-side. A permanent skeleton would read as "loading
// forever"; the sheet's own metadata and the download control below
// still work.
<p
data-slot="document-preview-unavailable"
className="text-muted-foreground py-8 text-center text-sm"
>
{title}
</p>
) : !loaded || src === null ? (
<Skeleton
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
/>
) : null}
{src === null || failed ? null : isPdf ? (
// No `sandbox` attribute by design: Chromium force-downloads PDFs
// in sandboxed frames instead of rendering them. The serve
// response itself carries `default-src 'none'` +
// `frame-ancestors 'self'` + true Content-Type + nosniff, and the
// upload policy denies every format whose inline render could
// execute script.
<iframe
ref={frameRef}
src={src}
title={title}
onLoad={(event) => {
setLoaded(true);
// The viewer may still initialise (and steal focus) after
// `load` — keep the guard open a little longer.
stealGuardUntil.current = Math.max(
stealGuardUntil.current,
Date.now() + 2_000,
);
reclaimFocus(event.currentTarget);
}}
onFocus={(event) => reclaimFocus(event.currentTarget)}
onPointerDown={() => {
// Deliberate interaction with the preview ends the guard.
stealGuardUntil.current = 0;
}}
className={cn(
"border-border h-[55vh] w-full rounded-lg border",
!loaded && "absolute inset-0 opacity-0",
)}
/>
) : (
// Decrypted same-origin blob stream: next/image cannot optimise it
// and would only proxy the bytes through a second request.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={title}
onLoad={() => setLoaded(true)}
className={cn(
"mx-auto max-h-[55vh] w-auto max-w-full rounded-lg object-contain",
!loaded && "absolute inset-0 opacity-0",
)}
/>
)}
</div>
);
}
@@ -0,0 +1,325 @@
"use client";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import type {
DocumentAiMode,
DocumentAiTarget,
} from "../document-ai-transport";
import { useIndexDocument } from "../use-content-index";
import {
useDocumentAiCapability,
useDocumentAiErrorText,
useDocumentsAutoAiRead,
useDocumentSummary,
useSuggestDetails,
} from "../use-document-assist";
import type { DocumentDetail } from "./use-document-detail";
/**
* The document's AI layer: the capability that drives the vision or text
* transport, the review-first suggestion and what was applied from it, the
* session-only and the stored summary, and content indexing.
*/
export function useDocumentDetailAi({
documentId,
open,
assistAvailable,
contentIndexEnabled,
detail,
}: {
documentId: string | null;
open: boolean;
assistAvailable?: boolean;
contentIndexEnabled?: boolean;
detail: DocumentDetail;
}) {
const { t, tCount } = useTranslations();
const queryClient = useQueryClient();
const { doc, canManageDocuments } = detail;
// AI layer — capability drives the vision/text transport; the mutations run
// the review-first assist, the session-only summary, and content indexing.
// The affordance itself is gated on `assistAvailable` (from usage); the
// probe only resolves the transport mode + the actionable unavailable reason.
//
// Neither probe fires inside somebody else's record. The
// document reads behind this sheet are delegable; the AI verbs above them are
// not, and both of these routes stay on the caller's own authentication. A
// delegate would fire two queries that must 403 in order to compute an
// affordance every branch below already withholds on `canManageDocuments`.
// Reading a document with AI follows the `documentAi` capability on
// `/api/auth/me` (the operator's "Reading documents" switch, the provider,
// the document-reading consent). Unavailable, the actions are simply not
// offered and the probe never fires; a missing consent is asked for in
// place instead.
const documentAi = useAiCapability("documentAi");
const coach = useAiCapability("coach");
const capability = useDocumentAiCapability(
canManageDocuments && open && documentId !== null && documentAi.available,
);
// When auto-read is ON, reading happens automatically on upload — the manual
// per-document AI action row is redundant and collapses away.
const autoRead = useDocumentsAutoAiRead(
canManageDocuments && open && documentId !== null,
);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
// Resolves an AI error to final copy — a 429 names the actual wait, derived
// from the reset instant the route mirrors into the error meta.
const aiErrorText = useDocumentAiErrorText();
// Stored generation has an independent mutation state so transient
// Summarise/Show text requests cannot change this block's spinner or result.
const storedSummary = useDocumentSummary();
const indexDoc = useIndexDocument();
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
null,
);
const [appliedFields, setAppliedFields] = useState({
title: false,
kind: false,
date: false,
});
const [summaryOutput, setSummaryOutput] =
useState<DocumentSummaryMode | null>(null);
// Reset transient AI state whenever another document opens.
const [lastDocumentId, setLastDocumentId] = useState(documentId);
if (lastDocumentId !== documentId) {
setLastDocumentId(documentId);
setSuggestion(null);
setAppliedFields({ title: false, kind: false, date: false });
setSummaryOutput(null);
}
// Clear stale mutation state (a prior document's assist error / summary)
// when the sheet switches documents or closes — the mutation stores live
// outside render, so they reset in an effect rather than during render.
useEffect(() => {
suggest.reset();
summary.reset();
storedSummary.reset();
indexDoc.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentId]);
const { aiEnabled, indexEnabled, autoReadEnabled, aiMode, aiTarget } =
aiGates({
documentAiAvailable: documentAi.available,
assistAvailable,
contentIndexEnabled,
capability: capability.data,
autoRead: autoRead.data,
doc,
});
const runSuggest = () => {
if (!aiTarget) return;
setAppliedFields({ title: false, kind: false, date: false });
suggest.mutate(
{ mode: aiMode, target: aiTarget },
{ onSuccess: (result) => setSuggestion(result) },
);
};
const runSummary = (output: DocumentSummaryMode) => {
if (!aiTarget) return;
summary.reset();
setSummaryOutput(output);
summary.mutate({ mode: aiMode, target: aiTarget, output });
};
// Generate or regenerate the STORED summary from the detail block. Only the
// stored-summary branch requests replacement; the empty-state action keeps
// first-write-wins if another worker or tab finishes while AI is running.
const generateStoredSummary = () => {
if (!aiTarget || !documentId) return;
storedSummary.reset();
storedSummary.mutate(
{
mode: aiMode,
target: aiTarget,
output: "summary",
persist: true,
replace: doc?.summary != null,
},
{
onSuccess: (result) => {
if ("summary" in result) {
if (result.persistence === "withheld") {
showErrorToast(result.summary);
} else if (result.persistence === "failed") {
showErrorToast(t("documents.detail.saveError"));
}
}
void queryClient.invalidateQueries({
queryKey: queryKeys.inboundDocument(documentId),
});
},
onError: (error) =>
showErrorToast(aiErrorText(error), { cause: error }),
},
);
};
const runIndex = () => {
if (!aiTarget) return;
indexDoc.mutate(
{ mode: aiMode, target: aiTarget },
{
// A read that continued into lab staging says so — the staged values
// are waiting in the review block, not silently in a queue. Staged
// facts stay PENDING; confirmation remains the only write into Labs.
onSuccess: (result) =>
toast.success(
result.labFactsStaged > 0
? tCount("documents.ai.readStaged", result.labFactsStaged)
: t("documents.ai.readDone"),
),
onError: (error) =>
showErrorToast(aiErrorText(error), { cause: error }),
},
);
};
const {
applyTitle,
applyKind,
applyDate,
suggestionKindLabel,
suggestionDateLabel,
} = useSuggestionApply({ suggestion, setAppliedFields, detail });
return {
documentAi,
coach,
capability,
suggest,
summary,
storedSummary,
indexDoc,
aiErrorText,
suggestion,
setSuggestion,
appliedFields,
summaryOutput,
setSummaryOutput,
aiEnabled,
indexEnabled,
autoReadEnabled,
runSuggest,
runSummary,
generateStoredSummary,
runIndex,
applyTitle,
applyKind,
applyDate,
suggestionKindLabel,
suggestionDateLabel,
};
}
/**
* Whether the AI actions run, whether content can be indexed, whether auto-
* read already does it, and the transport mode and target. The gate falls
* back to the capability probe when the sheet opens before usage loaded.
*/
function aiGates({
documentAiAvailable,
assistAvailable,
contentIndexEnabled,
capability,
autoRead,
doc,
}: {
documentAiAvailable: boolean;
assistAvailable?: boolean;
contentIndexEnabled?: boolean;
capability: ReturnType<typeof useDocumentAiCapability>["data"];
autoRead: ReturnType<typeof useDocumentsAutoAiRead>["data"];
doc: DocumentDetail["doc"];
}) {
const aiEnabled =
documentAiAvailable && (assistAvailable ?? capability?.available ?? false);
const indexEnabled =
documentAiAvailable &&
(contentIndexEnabled ?? capability?.available ?? false);
const autoReadEnabled = autoRead?.documentsAutoAiRead ?? false;
const aiMode: DocumentAiMode =
capability?.mode === "text" ? "text" : "vision";
const aiTarget: DocumentAiTarget | null = doc
? {
documentId: doc.id,
mimeType: doc.mimeType,
filename: doc.filename,
servingClass: doc.servingClass,
}
: null;
return { aiEnabled, indexEnabled, autoReadEnabled, aiMode, aiTarget };
}
type AppliedFields = { title: boolean; kind: boolean; date: boolean };
/**
* Review-first apply: the title lands in the EDITABLE field (the user still
* saves it via blur/Enter, the existing commit path); type and date each
* commit that single field on an explicit tap. Never automatic.
*/
function useSuggestionApply({
suggestion,
setAppliedFields,
detail,
}: {
suggestion: DocumentSuggestionDto | null;
setAppliedFields: (update: (prev: AppliedFields) => AppliedFields) => void;
detail: DocumentDetail;
}) {
const { t } = useTranslations();
const calendarDate = useCalendarDate();
const { patch, setTitleDraft, setEditingTitle } = detail;
const applyTitle = () => {
if (!suggestion?.title) return;
setTitleDraft(suggestion.title);
setEditingTitle(true);
setAppliedFields((prev) => ({ ...prev, title: true }));
};
const applyKind = () => {
if (!suggestion?.kind) return;
patch.mutate({ kind: suggestion.kind });
setAppliedFields((prev) => ({ ...prev, kind: true }));
};
const applyDate = () => {
if (!suggestion?.documentDate) return;
patch.mutate({ documentDate: suggestion.documentDate });
setAppliedFields((prev) => ({ ...prev, date: true }));
};
const suggestionKindLabel = suggestion?.kind
? t(`documents.kind.${suggestion.kind}`)
: null;
const suggestionDateLabel = suggestion?.documentDate
? calendarDate(suggestion.documentDate)
: null;
return {
applyTitle,
applyKind,
applyDate,
suggestionKindLabel,
suggestionDateLabel,
};
}
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more