mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
commit
c82ed69549
178 files changed
+19476
-15057
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+148
-7
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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*\{/);
|
||||
});
|
||||
});
|
||||
@@ -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
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.
|
||||
|
||||
+230
-2185
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;
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user