diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fd0a7d434..18e2a30b2 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -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 `. 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 diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index ee32c5dd8..fe3074434 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -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) | `` 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 ` - ) : undefined - } - /> - ); - } - - return ( - <> - {/* v1.4: dashboard tiles are *always* a single row. - * Maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row - * tile strip breaks the visual hierarchy and reads like an - * Excel grid. Total width caps at the parent container — - * exactly the chart-width below. When the active tile count - * exceeds what fits the viewport, the strip horizontal-scrolls - * instead of wrapping; the user trims the set in - * Settings → Dashboard (`/settings/dashboard`). - * Each tile keeps a `min-w-[10rem]` so a single tile still - * looks substantial on a wide screen, and `snap-x snap-mandatory` - * makes the scroll feel deliberate rather than arbitrary on - * touch. - */} - {/* v1.4.16: hide the strip entirely when the user - turned off every tile. Until v1.4.15 the wrapper rendered - an empty grid even with zero tiles — visually a thin gap - the maintainer described as "awkward". Charts below still render so - the page is not empty; the tile-strip just goes away. - The constraint the maintainer named — "immer die gesamte Spalte - breit und immer der gleichen Höhe" — is preserved by the - CSS-grid `auto-fit + minmax + auto-rows-fr` track that - continues to give every visible tile equal width / equal - height for any non-zero count. */} - {showTileStripSkeleton && ( - - )} - {trendCards.length > 0 && ( -
- {trendCards.map((entry) => { - /* - * Per-tile `` boundary with a layout-stable - * placeholder that mirrors the trend-card chrome. Tile - * bodies are synchronous today so the fallback rarely - * paints, but a future RSC hoist of any tile slot - * would otherwise leave the grid track empty and - * trigger CLS as the cell paints in. - * - * v1.16.0 — the fallback is the same structured - * silhouette the tile-strip skeleton paints, so a - * suspending tile slot previews the final shape - * instead of an empty card. - */ - const cell = ( - }> - {entry.node} - - ); - // A tile is no longer a dead-end: it links to its Insights - // detail page (`tile-destinations.ts`, gated on the page's - // module). The link fills the grid cell and paints a ring - // on hover / focus so the whole tile reads as tappable. - const href = dashboardTileHref(entry.id, user?.modules); - return href ? ( - - {cell} - - ) : ( -
- {cell} -
- ); - })} -
- )} - {charts.map((entry) => ( -
- {/* - * v1.4.40 — per-tile `` boundary so each - * chart paints independently rather than the row blocking - * on the slowest fetch (audit-H2 + brief C1). The - * `next/dynamic({ ssr: false })` lazy-load contract on - * `HealthChartDynamic` / `MoodChart` / - * `MedicationComplianceChart` already paints a - * `` while the JS chunk is in flight; this - * Suspense layer lifts the same skeleton to a streaming- - * compatible boundary so a future migration to - * `useSuspenseQuery` (when we replace the per-chart - * `["chart-data", …]` fetches with the slim-analytics- - * derived store) automatically buckets each chart's - * loading state to its own cell, with no further - * call-site changes. - * - * Today, the boundary is a structural no-op for the - * dynamic-loaded charts because their loading skeleton - * lives inside the dynamic factory. The benefit is - * future-proofing the composition: any descendant that - * later suspends (e.g. an RSC migration of a static - * legend, or a server-streamed sparkline) gets its own - * fallback without re-architecting the row. - */} - {entry.revealGated ? ( - /* v1.16.0 — gated cell: the chart mounts (its query - fires) but stays visually on the layout-stable - skeleton until the shared reveal flips — all gated - charts swap in the same frame with one short - fade-in (motion-safe). The TrendHint rides inside - the cell so it appears with its chart. */ - - }> - {entry.node} - - {entry.count != null ? ( - - ) : null} - - ) : ( - <> - }> - {entry.node} - - {entry.count != null ? ( - - ) : null} - - )} -
- ))} - {/* v1.16.8 — layout-stable chart-row reservation. While the - snapshot is in flight the data-gated chart entries above - cannot exist yet, so these silhouettes carry the row's - footprint; the moment the snapshot lands the real cells - render in their place (same render pass — no growth, a - swap). `aria-hidden` like the tile-strip skeleton: the - mounted charts announce their own loading state. */} - {chartRowPlaceholderCount > 0 && ( - - )} - - ); - })()} - - {/* v1.4: Getting-started checklist for brand-new users. Self-gates - * visibility on (onboardingCompletedAt == null || measurementCount - * < 5) and disappears once dismissed or fully complete. - * v1.16.8 — moved BELOW the tile strip + chart row: the card only - * mounts once the snapshot resolves, and sitting above the strip - * it pushed every tile and chart down when it popped in. At the - * bottom its late arrival shifts nothing — the fold-priority - * content (tiles, charts) keeps a stable position, and the - * near-empty dashboards of the new accounts the card targets keep - * it visible without scrolling anyway. */} + {/* The getting-started checklist sits below the tiles and charts: it + mounts only once the snapshot resolves, and above the strip its late + arrival pushed every tile down. It gates itself on onboarding state + and disappears once dismissed or complete. */} {demoMode ? null : } ); diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 21ea97e13..7517ab847 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -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"; diff --git a/src/components/admin/__tests__/backups-restore-preview.test.tsx b/src/components/admin/__tests__/backups-restore-preview.test.tsx index 892b55f2b..4107fe523 100644 --- a/src/components/admin/__tests__/backups-restore-preview.test.tsx +++ b/src/components/admin/__tests__/backups-restore-preview.test.tsx @@ -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( - + , ); const key = JSON.stringify(queryKeys.adminBackupSummary("b1")); diff --git a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx index f85d99c02..1fc6089c4 100644 --- a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx +++ b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx @@ -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"; diff --git a/src/components/admin/__tests__/backups-restore-skip-report.test.tsx b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx index e01f9353c..c5066e790 100644 --- a/src/components/admin/__tests__/backups-restore-skip-report.test.tsx +++ b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx @@ -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"; diff --git a/src/components/admin/__tests__/backups-section-empty.test.tsx b/src/components/admin/__tests__/backups-section-empty.test.tsx index 9c57e7eb3..b1d44b41d 100644 --- a/src/components/admin/__tests__/backups-section-empty.test.tsx +++ b/src/components/admin/__tests__/backups-section-empty.test.tsx @@ -17,10 +17,6 @@ const healthySchedule = { }; const mocks = vi.hoisted(() => ({ - mutationOptions: [] as Array<{ - onSuccess?: (data: { summary: Record }) => 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, @@ -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 }) => 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); }); }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 059bb8242..d8dcb62c2 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -7,845 +7,39 @@ * plus a "Run backup now" CTA that enqueues the pg-boss `data-backup` job. * The encrypted payload itself is never shipped to the browser; only the * size in bytes is surfaced. - * - * Folds in v1.4.6 deferred T2.6. */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertTriangle, - BookOpen, - Database, - Download, - History, - Loader2, - PlayCircle, - Upload, -} from "lucide-react"; -import { useRef, useState, type ChangeEvent } from "react"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/errors/error-toast"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; +import { useQuery } from "@tanstack/react-query"; +import { BookOpen, Database, Loader2, PlayCircle } from "lucide-react"; + import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity"; -import { Badge } from "@/components/ui/badge"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { EmptyState } from "@/components/ui/empty-state"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { ListRow } from "@/components/ui/list-row"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { formatBytes } from "@/lib/format/bytes"; +import { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { randomId } from "@/lib/random-id"; import type { BackupRow, BackupsList } from "@/types/backups"; -import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; -import type { - MissingBackupSection, - RestoreSkipSummary, - SkippedCatalogue, -} from "@/lib/export/restore-skips"; -import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore"; -import { getApiErrorMessage } from "./_shared"; + +import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status"; +import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows"; +import { BackupUploadCard } from "./backups/backup-upload-card"; import { - ReproofCancelledError, - throwIfReproofRequired, - useRecentProof, -} from "@/components/settings/security-section/use-recent-proof"; -import { - isActiveRestore, - RestoreJobStatus, - visibleRestoreJobs, -} from "./backup-restore-status"; -import { - ApiError, - apiFetch, - apiFetchRaw, - apiGet, - apiPost, -} from "@/lib/api/api-fetch"; - -/** - * 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). - */ -/** - * 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"; -} - -/** - * v1.37.20 — 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"] }, -]; - -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); - - // v1.37.20 — restore preview: fetch what the file contains the moment the - // dialog opens, so the typed confirmation is an informed one. A failed - // preview never blocks the restore — it states its own absence instead. - // - // 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(row.id), - queryFn: () => - apiGet<{ summary: Record }>( - `/api/admin/backups/${row.id}/summary`, - { signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) }, - ), - enabled: open, - staleTime: 60_000, - retry: false, - }); - const previewFailure = restorePreviewFailure(preview.error); - const numericSummary: Record = {}; - 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 ( - { - setOpen(next); - if (!next) { - setTyped(""); - setWithInstanceSettings(false); - } - }} - > - - - - - - - {t("admin.section.backups.restoreTitle")} - - - {t("admin.section.backups.restoreDescription", { - username: row.username, - when: fmt.dateTime(row.createdAt), - })} - - - {/* v1.37.20 — what the file contains, before the typed gate. */} -
-

- {t("admin.section.backups.previewTitle")} -

- {preview.isLoading && ( -

- {t("admin.section.backups.previewLoading")} -

- )} - {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. -

- {t("admin.section.backups.previewKeyMissing", { - keys: Array.isArray(preview.error.meta?.keyIds) - ? preview.error.meta.keyIds.map(String).join(", ") - : "", - })} -

- ) : previewFailure === "stillReading" ? ( -

- {t("admin.section.backups.previewStillReading")} -

- ) : ( -

- {t("admin.section.backups.previewUnavailable")} -

- ))} - {preview.isSuccess && ( -
    - {previewRows.map((entry) => ( -
  • - - {t(`admin.section.backups.${entry.labelKey}`)} - - - {fmt.integer(entry.count)} - -
  • - ))} - {otherCount > 0 && ( -
  • - - {t("admin.section.backups.previewOther")} - - - {fmt.integer(otherCount)} - -
  • - )} - {previewRows.length === 0 && otherCount === 0 && ( -
  • - {t("admin.section.backups.previewEmpty")} -
  • - )} -
- )} -
- {/* 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. */} - -
- - setTyped(e.target.value)} - autoComplete="off" - spellCheck={false} - placeholder="RESTORE" - /> -
- - {t("common.cancel")} - { - if (!matched) return; - setOpen(false); - setTyped(""); - onConfirm({ restoreInstanceSettings: withInstanceSettings }); - setWithInstanceSettings(false); - }} - > - {pending - ? t("admin.section.backups.restoreInProgress") - : t("admin.section.backups.restoreConfirm")} - - -
-
- ); -} - -/** - * 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["t"], -) { - if (type === "WEEKLY_AUTO") return t("admin.section.backups.typeWeeklyAuto"); - if (type === "MANUAL") return t("admin.section.backups.typeManual"); - return type; -} - -/** - * 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["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["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; -} - -/** - * 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. - */ -/** - * 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. - */ - -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 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 { - 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(); -} - -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", - ); -} - -export function RestoreRefusalNotice({ - sections, - onDismiss, -}: { - sections: MissingBackupSection[]; - onDismiss: () => void; -}) { - const { t } = useTranslations(); - if (sections.length === 0) return null; - return ( -
-
-
-
- -
-
- ); -} - -/** - * 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 ( -
-
-
-
- -
-
- ); -} - -/** - * 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. - */ -function ScheduleHealthNotice({ - schedule, -}: { - schedule: BackupScheduleStatus; -}) { - const { t } = useTranslations(); - const fmt = useFormatters(); - if (!schedule.stale && !schedule.lastRunFailed) return null; - - return ( -
-
-
-
- ); -} - -/** - * 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; - // v1.28 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, - }); -} + RestoreRefusalNotice, + RestoreSkipReport, + ScheduleHealthNotice, +} from "./backups/restore-notices"; +import { useBackupDownload, useRunBackup } from "./backups/use-backup-actions"; +import { useBackupRestores } from "./backups/use-backup-restores"; +import { useBackupUpload } from "./backups/use-backup-upload"; export function BackupsSection() { - const { t, locale } = useTranslations(); - const fmt = useFormatters(); - const queryClient = useQueryClient(); + const { t } = useTranslations(); const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), @@ -853,286 +47,40 @@ export function BackupsSection() { return apiGet("/api/admin/backups"); }, }); - - const runBackup = 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 }, - ); - }, - }); - + const runBackup = useRunBackup(); const rows: BackupRow[] = data?.rows ?? []; - // 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(null); - - // Upload state — single-file flow, drives the file input + button. - const fileInputRef = useRef(null); // Download, upload and restore reach every account's data, so each asks for // a fresh proof unless this session signed in or re-proved within minutes. const recentProof = useRecentProof(); - 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 = - data.summary.measurements + - data.summary.medications + - data.summary.intakeEvents + - data.summary.moodEntries + - (data.summary.medicationSideEffects ?? 0) + - (data.summary.cycles ?? 0) + - (data.summary.cycleDayLogs ?? 0) + - (data.summary.labResults ?? 0) + - (data.summary.biomarkers ?? 0) + - (data.summary.illnessEpisodes ?? 0) + - (data.summary.illnessDayLogs ?? 0) + - (data.summary.allergies ?? 0) + - (data.summary.familyHistory ?? 0) + - (data.summary.workouts ?? 0) + - (data.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. - (data.summary.nutrientDays ?? 0) + - (data.summary.healthProfile ?? 0) + - (data.summary.healthProfileFactRevisions ?? 0) + - (data.summary.customMetrics ?? 0) + - (data.summary.customMetricEntries ?? 0) + - (data.summary.intradayProfiles ?? 0); - 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) { - const file = e.target.files?.[0]; - if (file) upload.mutate(file); - } - - // 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(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>(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>( - () => 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 }, - ); - }, - }); - - 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); - } - } + const { downloadingId, handleDownload } = useBackupDownload(recentProof); + const { upload, fileInputRef, handleFileChange } = + useBackupUpload(recentProof); + const { + restoreJobs, + dismissedRestores, + dismissRestore, + restore, + restorePending, + skipped, + setSkipped, + refusedSections, + setRefusedSections, + } = useBackupRestores(recentProof); + const rowActions = { + downloadingId, + onDownload: (row: BackupRow) => void handleDownload(row), + restorePending, + onRestore: ( + row: BackupRow, + options: { restoreInstanceSettings: boolean }, + ) => restore.mutate({ row, ...options }), + }; return ( {recentProof.dialog} - {/* v1.18.1 — the snapshot count leads (numbers first) and the + {/* The snapshot count leads (numbers first) and the "Run now" button follows. */} ) : null} - {/* 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 / screen-reader users keep a labelled control. */} -
-
-
- {t("admin.section.backups.uploadTitle")} -
-

- {t("admin.section.backups.uploadDescription")}{" "} - - {t("admin.section.backups.uploadHelp")} - -

-
-
- - -
-
+ {visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => ( void refetch()} /> ) : rows.length === 0 ? ( - // v1.4.15: replace bare text with the EmptyState + // Replace bare text with the EmptyState // primitive. The header already exposes "Backup now" but a // brand-new admin lands inside the card and benefits from a // duplicate CTA right next to the explanation. @@ -1283,155 +192,9 @@ export function BackupsSection() { ) : (
- {/* Wide layout: the canonical table. Hidden below `md`, where the - five columns push Download / Restore off-screen behind a - horizontal scroll; the card list below takes over there. */} -
- - - - - - - - - - - {/* `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. */} - - {rows.map((row, i) => ( - - - - - - - - ))} - -
- {t("admin.section.backups.colUser")} - - {t("admin.section.backups.colType")} - - {t("admin.section.backups.colSize")} - - {t("admin.section.backups.colCreatedAt")} - - {t("admin.section.backups.colActions")} -
{row.username} - - {formatBackupType(row.type, t)} - - - {formatBytes(row.sizeBytes, locale)} - - {fmt.dateTime(row.createdAt)} - -
- - - restore.mutate({ row, ...options }) - } - /> -
-
-
+ - {/* Narrow layout: each backup as a self-contained card with the - meta line + a flex-wrapping action row, so Download / Restore - stay reachable on a phone instead of scrolling off-screen. */} -
    - {rows.map((row) => ( - -
  • -
    - {row.username} - - {formatBackupType(row.type, t)} - -
    -

    - {t("admin.section.backups.colSize")}:{" "} - {formatBytes(row.sizeBytes, locale)} ·{" "} - {fmt.dateTime(row.createdAt)} -

    -
    - - - restore.mutate({ row, ...options }) - } - /> -
    -
  • -
    - ))} -
+

{t("admin.section.backups.retentionLabel", { diff --git a/src/components/admin/backups/backup-rows.tsx b/src/components/admin/backups/backup-rows.tsx new file mode 100644 index 000000000..316148eb8 --- /dev/null +++ b/src/components/admin/backups/backup-rows.tsx @@ -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["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 ( + <> + + 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 ( +

+ + + + + + + + + + + {/* `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. */} + + {rows.map((row, i) => ( + + + + + + + + ))} + +
+ {t("admin.section.backups.colUser")} + + {t("admin.section.backups.colType")} + + {t("admin.section.backups.colSize")} + + {t("admin.section.backups.colCreatedAt")} + + {t("admin.section.backups.colActions")} +
{row.username} + + {formatBackupType(row.type, t)} + + + {formatBytes(row.sizeBytes, locale)} + + {fmt.dateTime(row.createdAt)} + +
+ +
+
+
+ ); +} + +/** + * 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 ( +
    + {rows.map((row) => ( + +
  • +
    + {row.username} + + {formatBackupType(row.type, t)} + +
    +

    + {t("admin.section.backups.colSize")}:{" "} + {formatBytes(row.sizeBytes, locale)} ·{" "} + {fmt.dateTime(row.createdAt)} +

    +
    + +
    +
  • +
    + ))} +
+ ); +} diff --git a/src/components/admin/backups/backup-upload-card.tsx b/src/components/admin/backups/backup-upload-card.tsx new file mode 100644 index 000000000..e4d386520 --- /dev/null +++ b/src/components/admin/backups/backup-upload-card.tsx @@ -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; + onFileChange: (event: ChangeEvent) => void; +}) { + const { t } = useTranslations(); + return ( +
+
+
+ {t("admin.section.backups.uploadTitle")} +
+

+ {t("admin.section.backups.uploadDescription")}{" "} + + {t("admin.section.backups.uploadHelp")} + +

+
+
+ + +
+
+ ); +} diff --git a/src/components/admin/backups/restore-dialog.tsx b/src/components/admin/backups/restore-dialog.tsx new file mode 100644 index 000000000..73a091cbb --- /dev/null +++ b/src/components/admin/backups/restore-dialog.tsx @@ -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 }>( + `/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 = {}; + 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 ( +
+

+ {t("admin.section.backups.previewTitle")} +

+ {preview.isLoading && ( +

+ {t("admin.section.backups.previewLoading")} +

+ )} + {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. +

+ {t("admin.section.backups.previewKeyMissing", { + keys: Array.isArray(preview.error.meta?.keyIds) + ? preview.error.meta.keyIds.map(String).join(", ") + : "", + })} +

+ ) : previewFailure === "stillReading" ? ( +

+ {t("admin.section.backups.previewStillReading")} +

+ ) : ( +

+ {t("admin.section.backups.previewUnavailable")} +

+ ))} + {preview.isSuccess && ( +
    + {previewRows.map((entry) => ( +
  • + + {t(`admin.section.backups.${entry.labelKey}`)} + + {fmt.integer(entry.count)} +
  • + ))} + {otherCount > 0 && ( +
  • + + {t("admin.section.backups.previewOther")} + + {fmt.integer(otherCount)} +
  • + )} + {previewRows.length === 0 && otherCount === 0 && ( +
  • + {t("admin.section.backups.previewEmpty")} +
  • + )} +
+ )} +
+ ); +} + +/** + * 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 ( + { + setOpen(next); + if (!next) { + setTyped(""); + setWithInstanceSettings(false); + } + }} + > + + + + + + + {t("admin.section.backups.restoreTitle")} + + + {t("admin.section.backups.restoreDescription", { + username: row.username, + when: fmt.dateTime(row.createdAt), + })} + + + {/* What the file contains, before the typed gate. */} + + {/* 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. */} + +
+ + setTyped(e.target.value)} + autoComplete="off" + spellCheck={false} + placeholder="RESTORE" + /> +
+ + {t("common.cancel")} + { + if (!matched) return; + setOpen(false); + setTyped(""); + onConfirm({ restoreInstanceSettings: withInstanceSettings }); + setWithInstanceSettings(false); + }} + > + {pending + ? t("admin.section.backups.restoreInProgress") + : t("admin.section.backups.restoreConfirm")} + + +
+
+ ); +} diff --git a/src/components/admin/backups/restore-notices.tsx b/src/components/admin/backups/restore-notices.tsx new file mode 100644 index 000000000..bb1dd6bff --- /dev/null +++ b/src/components/admin/backups/restore-notices.tsx @@ -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["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["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 ( +
+
+
+
+ +
+
+ ); +} + +/** + * 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 ( +
+
+
+
+ +
+
+ ); +} + +/** + * 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 ( +
+
+
+
+ ); +} diff --git a/src/components/admin/backups/use-backup-actions.ts b/src/components/admin/backups/use-backup-actions.ts new file mode 100644 index 000000000..033ea391b --- /dev/null +++ b/src/components/admin/backups/use-backup-actions.ts @@ -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, +) { + 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(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 }; +} diff --git a/src/components/admin/backups/use-backup-restores.ts b/src/components/admin/backups/use-backup-restores.ts new file mode 100644 index 000000000..97531da48 --- /dev/null +++ b/src/components/admin/backups/use-backup-restores.ts @@ -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, +) { + 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(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>(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>( + () => 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, + }; +} diff --git a/src/components/admin/backups/use-backup-upload.ts b/src/components/admin/backups/use-backup-upload.ts new file mode 100644 index 000000000..30c7f7efc --- /dev/null +++ b/src/components/admin/backups/use-backup-upload.ts @@ -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 { + 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>["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, +) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + // Upload state — single-file flow, drives the file input + button. + const fileInputRef = useRef(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) { + const file = e.target.files?.[0]; + if (file) upload.mutate(file); + } + + return { upload, fileInputRef, handleFileChange }; +} diff --git a/src/components/charts/__tests__/health-chart-annotations.test.ts b/src/components/charts/__tests__/health-chart-annotations.test.ts index 70e148ea0..fff06fb22 100644 --- a/src/components/charts/__tests__/health-chart-annotations.test.ts +++ b/src/components/charts/__tests__/health-chart-annotations.test.ts @@ -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. diff --git a/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx b/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx index 8f379853b..4e8727718 100644 --- a/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx +++ b/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx @@ -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 `` 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) => { ... \{[\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", () => { diff --git a/src/components/charts/__tests__/health-chart-polish.test.tsx b/src/components/charts/__tests__/health-chart-polish.test.tsx index 9a69d0f85..789ca2b7f 100644 --- a/src/components/charts/__tests__/health-chart-polish.test.tsx +++ b/src/components/charts/__tests__/health-chart-polish.test.tsx @@ -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. diff --git a/src/components/charts/__tests__/health-chart-target-link-removed.test.ts b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts index 69b884356..ea9102dd0 100644 --- a/src/components/charts/__tests__/health-chart-target-link-removed.test.ts +++ b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts @@ -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"); diff --git a/src/components/charts/__tests__/health-chart-type-label.test.tsx b/src/components/charts/__tests__/health-chart-type-label.test.tsx index d9880f3cf..aaefdf00d 100644 --- a/src/components/charts/__tests__/health-chart-type-label.test.tsx +++ b/src/components/charts/__tests__/health-chart-type-label.test.tsx @@ -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", diff --git a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts index 8b8a8ef6e..a6d5f91b1 100644 --- a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts +++ b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts @@ -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. diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index f42233f75..909ec7a4d 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1,591 +1,77 @@ "use client"; -import { chartSeriesParams } from "@/components/charts/chart-series-request"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { useMemo, useState } from "react"; import { - localDayKeyFor, - openEndedFetchWindow, -} from "@/lib/charts/fetch-window"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; -import { - ResponsiveContainer, + CartesianGrid, ComposedChart, - Line, - Area, + Legend, + ResponsiveContainer, + Tooltip, XAxis, YAxis, - CartesianGrid, - Tooltip, - Legend, - ReferenceLine, - ReferenceArea, - ReferenceDot, } from "recharts"; -import { - useState, - useMemo, - useEffect, - useCallback, - useRef, - type ComponentType, -} from "react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; -import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; -import { ChartEmptyState } from "./chart-empty-state"; -import { ChartErrorState } from "./chart-error-state"; -import { TileHeader } from "@/components/insights/tile-header"; -import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; -import { - useDateFormatPreference, - useTranslations, - useFormatters, -} from "@/lib/i18n/context"; -import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; -import { - bucketTimeSeries, - pickBucket, - type ChartBucketType, -} from "@/lib/charts/bucket-time-series"; -import { - computeWindowTrend, - SPLIT_HALF_THRESHOLD_DAYS, -} from "@/lib/analytics/window-trend"; -import { resolveMiniRangePoints, type DataWindow } from "./mini-window"; -import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift"; -import type { - ChartOverlayKey, - ComparisonBaseline, -} from "@/lib/dashboard-layout"; -import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; -import { bucketCaptionKey } from "@/lib/charts/bucket-caption"; -import type { MeasurementType } from "@/generated/prisma/client"; -import { ChartOverlayControls } from "./chart-overlay-controls"; -import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table"; -import { ChartFooterReserve } from "./chart-skeleton"; -import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; -import { useViewportWidth } from "@/hooks/use-viewport-width"; -import { computeTickPositions } from "@/lib/charts/x-axis-density"; -import { - computeWindowStats, - type MetricWindowStats, -} from "@/lib/charts/window-stats"; -import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; -import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; + import { chartPointDayKey } from "@/components/day/chart-day"; import { ChartDayFooter, - OPEN_DAY_LINE, useChartDayLinks, } from "@/components/day/chart-day-links"; import { isWholeNumberType } from "@/components/day/use-day-value-format"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useViewportWidth } from "@/hooks/use-viewport-width"; +import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; +import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { computeTickPositions } from "@/lib/charts/x-axis-density"; import { - ChartRangeTabs, - DEFAULT_CHART_RANGE, - rangeWindowDays, -} from "./chart-range-tabs"; -import { trendLineByTime } from "@/lib/stats/linear-fit"; -import { translateOrNull } from "@/lib/i18n/translate-code"; + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and -// the "All" tab's ten-year fetch span live with the shared -// ``, so every chart drawn in days offers the same choice. - -interface HealthChartProps { - types: string[]; - title: string; - colors?: string[]; - unit?: string; - yAxisUnit?: string; - valueMode?: "raw" | "bmi"; - yAxisWidth?: number; - showYAxisUnit?: boolean; - valueBands?: Array<{ - min: number; - max: number; - color: string; - opacity?: number; - strokeOpacity?: number; - }>; - targetZones?: Array<{ - min: number; - max: number; - color: string; - opacity?: number; - label?: string; - textColor?: string; - lineOpacity?: number; - }>; - /** - * v1.4.16 — compact chart mode used by the Oura-style - * rationale card. Drops the range tabs, the moving-average / trend - * / target-band toggle row, and shrinks padding. Tooltip + gradient - * + personal-baseline behaviour stay intact so the small chart - * still tells the same visual story. - */ - mini?: boolean; - /** - * v1.4.16 — pin the chart to a specific rationale data - * window regardless of any parent UI state. Used by the rec-card's - * mini-chart so it always renders the window the rec is based on. - */ - windowOverride?: DataWindow; - /** - * v1.4.16 — when set to "lastMonth" / "lastYear", paint a - * dimmed prior-period overlay beneath the current series. The chart - * already fetches the full measurement history; the overlay is - * computed by `shiftDailySeriesForward` so the prior-period day at - * 30 / 365 days back lands directly under its current-period sibling - * on the visible x-axis. - * - * "none" or undefined renders the chart exactly as before — no - * regression for users who keep the toggle off. - */ - compareBaseline?: ComparisonBaseline; - /** - * v1.4.18 — per-chart overlay-prefs key. When supplied, the chart - * mounts an overlay-controls dropdown in the header and reads its - * three toggle states from the persisted user prefs. When omitted - * (mini mode, ad-hoc usage) the chart renders without controls and - * its toggles default to OFF (clean line). - */ - chartKey?: ChartOverlayKey; - /** - * v1.42 — 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), so binding a key to remember the range does not add - * controls the chart never had. Defaults to on. - */ - overlayControls?: boolean; - /** - * v1.4.20 — additive storyboard annotations. - * - * Each entry pins a vertical reference line + label to the data point - * whose Berlin-day key matches `date` (`YYYY-MM-DD`). The chart line - * + tooltip + tooltip-deltas remain untouched — Recharts' source-order - * layering paints `` between the cartesian grid and - * the data lines so the annotation reads as orientation, not data. - * - * Annotations whose date falls outside the visible window silently - * drop (we already use `ifOverflow="discard"` on every reference line - * that needs the same behaviour). - */ - annotations?: Array<{ date: string; label: string; color: string }>; - /** - * v1.4.25 — vertical injection-day markers. - * - * Each entry pins a thin dashed vertical reference line on the chart - * at the data point whose Berlin/user-tz day-key matches `date` - * (`YYYY-MM-DD`). Designed for the GLP-1 dashboard tile + the - * Insights /medikamente sub-page so users can see "I injected on - * these days; here's how my weight responded". Differs from - * `annotations` in three ways: - * 1. No text label by default — these markers represent events - * the user already saw the chart-line shape for; an inline - * label per marker would crowd the canvas. - * 2. A small filled dot sits at the x-axis intersection (mood- - * chart style emoji-on-axis pattern repurposed) so a row of - * green dots reads as "injection cadence" at a glance. - * 3. Default green (`var(--success)`) to match the strip-tile palette - * for active medications; callers can override per marker. - * - * Off-window markers silently drop via `ifOverflow="discard"`, - * matching the existing annotations contract. - */ - verticalMarkers?: Array<{ date: string; label?: string; color?: string }>; - /** - * v1.4.25 — per-user display timezone. When passed (mount sites - * thread `useAuth().user?.timezone`), x-axis tick labels and the - * per-day bucket keys both render in the user's zone. When a mount - * passes none, the chart uses the signed-in account's zone (which - * `/api/auth/me` reports already resolved against the server default), - * and only before that has loaded the shared `DEFAULT_TIMEZONE` the - * server itself falls back to. - */ - userTimezone?: string; - /** - * v1.7.0 — display-time scalar applied to every raw value before it - * enters the chart pipeline (bucketing, y-domain, personal baseline, - * trend, tooltip). Used by the unit-fix mounts to render a metric in - * a derived display unit (e.g. WALKING_SPEED m/s → km/h via - * `valueScale={3.6}`) while raw storage stays canonical SI. - * - * Defaults to `1` — the identity scale — so every existing chart - * renders byte-identical to the pre-v1.7.0 behaviour. Recharts is - * untouched; the scale is folded into the value at the single read - * boundary so all downstream math operates on the scaled series - * uniformly. - */ - valueScale?: number; - /** - * v1.32.26 — display-time additive offset folded in alongside - * `valueScale` at the same read boundary: `display = raw * valueScale + - * valueOffset`. Required for the affine °C→°F conversion (a shift, not - * just a scale). Defaults to `0`. Because every tooltip delta / trend - * slope is computed as a DIFFERENCE of two offset values, the offset - * cancels there automatically — a rate never inherits the +32 shift. - */ - valueOffset?: number; - /** - * v1.12.8 — chart-reactive metric statistics. When supplied, the chart - * reports the per-type Min / Max / Median / Mean of the data currently - * visible under the active range tab (the 7 / 30 / 90 / All selector), - * keyed by `MeasurementType`. It fires on mount and whenever the range tab - * (or the underlying series) changes, so the `` a sub-page - * lifts the callback into always reflects the same window the chart paints. - * - * Mini mode never reports — the rationale card pins its own window and has - * no stat strip. The dashboard / ad-hoc mounts simply omit the callback. - */ - onVisibleStats?: (stats: Record | null) => void; - /** - * v1.12.8 — optional leading glyph for the chart card's ``. - * When supplied, the chart header renders the canonical icon + title row - * (same size + spacing as every other Insights tile) instead of the bare - * `

`. Omitted on the dashboard / mini mounts, which keep the compact - * heading. Accepts any component that takes a `className` (the contract - * `` and the stat strip's `icon` already use), so a page can - * thread the same glyph it hands the stat strip. - */ - titleIcon?: ComponentType<{ className?: string }>; - /** - * v1.16.0 — fires once the chart's data query has settled (initial - * load finished, success or error). The dashboard's shared reveal - * gate listens here so every chart cell swaps from skeleton to - * content in one frame instead of popping in one after another. - * Optional and repeat-safe: the dashboard's `markReady` is - * idempotent; non-dashboard mounts simply omit it. - */ - onDataReady?: () => void; - /** - * v1.18.6 — pre-fetched daily series, keyed by `MeasurementType`. When - * the dashboard batches every visible chart's series into ONE - * `/api/measurements/series-batch` round-trip (audit finding #2), it - * passes each chart its slice here. When supplied, the chart's queryFn - * consumes these rows for the covered types instead of issuing its own - * per-type `/api/measurements` fetch. Omitted on non-dashboard mounts - * (insights sub-pages, mini cards) which keep self-fetching. - * SLEEP_DURATION is never pre-fetched (it rides the per-night `/series` - * adapter), so a sleep chart self-fetches that branch regardless. - * - * The rows must already be scoped to the chart's active fetch window; - * the prop is keyed into the query cache so a range-tab change (wider - * window) re-fetches cleanly rather than blending a batched slice with - * a self-fetched one. - */ - preloadedSeries?: Record; - /** - * v1.19.0 — the day-span the `preloadedSeries` slice actually covers - * (the window the dashboard's batched fetch requested). The chart reads - * the preloaded slice ONLY when the active range tab's window fits - * within this coverage; a wider tab (90 / All on a 30-day batch) falls - * through to a real fetch for the requested window instead of silently - * re-rendering the narrow batched slice. - * - * Required whenever `preloadedSeries` is supplied. Mount sites set it to - * the exact day-span their batch fetches (the dashboard batch spans 31 - * days). Omitted on self-fetching mounts. - */ - preloadedCoverageDays?: number; - /** - * Render the drawn points as a table beneath the chart, behind a - * collapsed disclosure. Opt-in: the metric sub-pages want it (a chart - * shows the shape and hides the numbers, and an appointment needs the - * numbers), the dashboard tiles do not — a table under every tile would - * bury the dashboard it is meant to summarise. - */ - showDataTable?: boolean; - /** - * v1.42 — the chart is a door to its days. A click on a point opens that - * day over the page (on a touch screen the tooltip offers it), the open day - * is marked with a dashed line, and a row of dots under the axis marks the - * days that hold anything. Only while the chart is drawn in days, and never - * in `mini` mode: a tile's sparkline has its own destination and targets - * too small to hit. The metric sub-pages turn it on; the dashboard does not. - */ - dayLinks?: boolean; -} - -interface ChartDataPoint { - date: string; - timestamp: number; - pointIndex?: number; - // v1.8.5 — `${type}__range` keys carry a `[min, max]` tuple for the - // range-band ; every other key stays a scalar. - [key: string]: string | number | undefined | [number, number]; -} - -interface MeasurementApiRow { - measuredAt: string; - value: number; - // v1.8.5 — per-day min / max emitted by the rollup daily-aggregate - // path (non-cumulative metrics only). Drive the Apple-Health-style - // range band shaded around the mean line. Not drawn on the 7-day range - // and absent on cumulative metrics. - // v1.18.6 — `null` accepted alongside `undefined` so the batched - // series endpoint's row shape (which carries `number | null`) feeds - // `preloadedSeries` directly. The bucketing loop already guards each - // with a `typeof … === "number"` check, so a null is ignored exactly - // like an absent value. - minValue?: number | null; - maxValue?: number | null; - // v1.4.43 — the rollup / daily-aggregate paths return - // the underlying raw-row count per bucket. Used downstream to gate - // the "more days needed" empty-state copy on the actual measurement - // count rather than the number of distinct calendar days. Optional - // because the per-night sleep series does not carry it. - count?: number; -} - -interface VisibleTargetZone { - key: string; - low: number; - high: number; - topPct: number; - heightPct: number; - color: string; - opacity: number; - label: string | undefined; - textColor: string | undefined; - lineOpacity: number; -} - -const BASE_TYPE_LABEL_KEYS: Record = { - 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", -}; +import { ChartDataTable } from "./chart-data-table"; +import { ChartEmptyState } from "./chart-empty-state"; +import { ChartErrorState } from "./chart-error-state"; +import { DEFAULT_CHART_RANGE } from "./chart-range-tabs"; +import { ChartFooterReserve } from "./chart-skeleton"; +import { ChartBandOverlay } from "./health-chart/band-overlay"; +import { HealthChartHeader } from "./health-chart/chart-header"; +import { + annotationLayers, + baselineLayers, + openDayLayers, + seriesLayers, + targetZoneLayers, + verticalMarkerLayers, +} from "./health-chart/chart-layers"; +import { makeTooltipContent } from "./health-chart/chart-tooltip-content"; +import { + getTypeLabel, + makeDayKeyFormatter, + resolveAnnotationPositions, + resolveVerticalMarkerPositions, +} from "./health-chart/series-math"; +import type { ChartDataPoint, HealthChartProps } from "./health-chart/types"; +import { + useHealthChartLabels, + useHealthChartOverlays, +} from "./health-chart/use-health-chart-overlays"; +import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; +import { useHealthChartView } from "./health-chart/use-health-chart-view"; +import { resolveMiniRangePoints } from "./mini-window"; /** - * 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. + * A metric over time: the line, the overlays a person can switch on (the + * 7-day average, the trend, the target range, the prior period), reference + * bands and zones, annotations and event markers, the range tabs, the + * tooltip, and the days as doors. The parts live in `./health-chart/`: the + * series read, the derived view, the overlays and labels, the header, the + * plot layers and the tooltip; this component wires them together. */ -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 - * (a resting-pulse trend read "RESTING_HEART_RATE"). - */ -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; -} - -/** - * Build a `YYYY-MM-DD` day-key formatter pinned to the requested - * timezone. Memoised per-tz inside the component so we don't allocate a - * new `Intl.DateTimeFormat` on every measurement-row pass. - */ -function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat { - return new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); -} - -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}`; -} - -function dayKeyToTimestamp(dayKey: string): number { - const [year, month, day] = dayKey.split("-").map(Number); - return Date.UTC(year, month - 1, day, 12, 0, 0); -} - -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, - }; - }); -} - -/** - * v1.4.16 — personal-baseline helper. - * - * Computes the median (50th-percentile) value of a daily-aggregated - * series, capped at the most-recent 90 daily points (Apple Health uses - * the rolling-90-day window for "your normal" framing). Returns `null` - * when fewer than 5 points exist — a baseline drawn off 1-2 readings is - * misleading; surface nothing instead. - * - * Pure & deterministic so the unit test pins exact medians. - */ -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]; -} - -/** - * v1.4.20 — pure helper that maps storyboard annotations to - * visible chart pointIndex positions. Exported so the unit suite can - * pin behaviour without rendering Recharts. Returns the subset of - * annotations that fall on (or within 7 days of) a visible bucketed - * point, with a 24-char truncation precomputed for `` also - * uses `ifOverflow="discard"` as a belt-and-braces guard). - */ -export interface ResolvedVerticalMarker { - pointIndex: number; - color: string; - label: string | undefined; -} - -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(); - for (const [i, point] of chartData.entries()) { - // Last-write-wins — multiple bucket-aggregated points should never - // share the same day key, but defensively keep the latest if they - // do. 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; -} - -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; - } - } - // Drop annotations more than 7 days off any visible point — long - // bucket windows (monthly aggregation) would otherwise snap multi- - // month events to the closest visible bucket and stack labels. - 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; -} - export function HealthChart({ types, title, @@ -616,46 +102,34 @@ export function HealthChart({ dayLinks = false, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); - // A mount without the prop used to pin Europe/Berlin, so those charts - // cut days in Berlin for every user whatever their profile said. + // A mount without the prop used to pin Europe/Berlin, so those charts cut + // days in Berlin for every user whatever their profile said. const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE; const { t, locale } = useTranslations(); const fmt = useFormatters(); - // Issue #490 — x-axis tick + tooltip date labels. Every timestamp this - // chart formats encodes a CALENDAR DAY (noon-UTC of a profile-tz day - // key, or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), - // so labels render UTC-pinned via `makeBucketLabelFormatters` — the - // encoded day paints verbatim in every profile zone. Rendering these - // encodings through a profile-tz formatter shifted week/month bucket - // labels a day/month back for zones west of Berlin. Day-key BUCKETING - // below still uses `userTimezone` — only labels are pinned. - // Issue #922 — the UTC pin decides WHICH day the label names; the - // profile's date order decides how it is spelled. Both travel. + // Every timestamp the chart labels encodes a CALENDAR DAY (noon UTC of a + // profile-zone day key, or a bucket start), so labels render UTC-pinned + // and the encoded day paints verbatim in every zone (#490); the profile's + // date order decides how it is spelled (#922). Bucketing still uses the + // profile zone. const dateFormat = useDateFormatPreference(); const tzFmt = useMemo( () => makeBucketLabelFormatters(locale, dateFormat), [locale, dateFormat], ); - // v1.4.25 — per-row day-key formatter used to bucket measurement - // rows by the user's local calendar day. Memoised on userTimezone so - // the inner per-row loop reuses a single Intl.DateTimeFormat. const dayKeyFormatter = useMemo( () => makeDayKeyFormatter(userTimezone), [userTimezone], ); - // v1.4.16: when a windowOverride is supplied, seed the range - // state from it so the chart pins to that window. Mini mode also - // hides the range tabs, so the user can't change it. + // A `windowOverride` pins the range (mini mode hides the tabs anyway). const initialRangePoints = windowOverride ? resolveMiniRangePoints(windowOverride) : DEFAULT_CHART_RANGE; const [rangePoints, setRangePoints] = useState(initialRangePoints); - // v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow - // / showTargetRange) are persisted per chart via the new - // `useChartOverlayPrefs` hook. When the chart isn't bound to a - // persistent key (mini mode / ad-hoc render) the hook short-circuits - // to the clean-line default and skips its dashboard-layout fetch. + // The overlay toggles and the comparison persist per chart; an unbound + // chart (mini mode, ad-hoc use) gets the clean-line default and skips the + // layout read. const overlayPrefs = useChartOverlayPrefs(chartKey); const showMA = overlayPrefs.prefs.showTrendIndicator; const showTrend = overlayPrefs.prefs.showTrendArrow; @@ -664,21 +138,11 @@ export function HealthChart({ ? overlayPrefs.prefs.comparisonBaseline : compareBaseline; - // v1.30.1 — hydrate the range tab from the persisted prefs once - // they arrive, mirroring the overlay toggles above. A plain - // `useState(30)` initializer can't see the persisted value because - // the dashboard-layout query resolves after mount; `rangePoints` also - // drives several derived memos below by identity, so it stays local - // state (unlike the toggles, which read `overlayPrefs.prefs` live) - // and is synced in exactly once. `windowOverride` charts (mini mode) - // are pinned and never adopt a persisted range. Adjusted during - // render (React's documented "adjust state during render" escape - // hatch, not an effect — the lint rule that bans ref reads during - // render is why this is a second piece of state rather than a ref) - // so the very first render that sees the resolved prefs already - // reflects them; `rangeHydrated` guards against re-running past the - // first hit, so a later cache refetch can't stomp a range the user - // already picked this session. + // Adopt the persisted range tab once the prefs arrive. Adjusted during + // render (React's documented escape hatch, not an effect), so the first + // render that sees the prefs already reflects them; `rangeHydrated` stops a + // later refetch from stomping a range picked this session. Pinned charts + // never adopt one. const [rangeHydrated, setRangeHydrated] = useState(false); if ( !windowOverride && @@ -690,771 +154,78 @@ export function HealthChart({ setRangePoints(overlayPrefs.prefs.rangePoints); } - // v1.4.19 — viewport-aware tick density. Recharts' default is one - // tick per data point; the helper caps that at 4-10 visible labels - // depending on viewport width so the X-axis reads consistently across - // every chart card. + // Viewport-aware tick density: 4 to 10 labels, whatever the point count. const viewportWidth = useViewportWidth(); - // v1.36.1 — `useAuth()` answers about the person at the keyboard, and under a - // switch that is the delegate. Dividing the OWNER's weights by the - // DELEGATE's height squared produces a plausible-looking BMI that is simply - // somebody else's, shaded by fixed category bands, on a page that names the - // owner. The same substitution was fixed for the reference bands in this - // release; this is the same mistake one component over, and a wrong health - // figure is worse than an absent one. - // - // Inside a shared record the chart therefore draws no BMI at all rather than - // a wrong one. Publishing the owner's height to every client that can read - // their weights is a bigger decision than this fix, and it belongs with the - // per-module scope work rather than here. + // `useAuth()` is the person at the keyboard, and under a switch that is the + // delegate: dividing the owner's weights by the delegate's height squared + // gives a plausible BMI that is somebody else's. Inside a shared record the + // chart draws no BMI rather than a wrong one. const { inSharedRecord } = useRecordCapabilities(); const bmiDivisor = valueMode === "bmi" && !inSharedRecord && user?.heightCm ? (user.heightCm / 100) ** 2 : null; - // v1.4.28 (R1.2 H0) — derive a bounded date window from the - // active range selector so the chart fetches only what it will - // actually render. The legacy `while (true)` paginated walk pulled - // tens of thousands of pulse rows on every visit; the bounded - // window + the server-side aggregation hint keeps every navigation - // payload-flat regardless of underlying density. - // - // Comparison overlays (lastMonth / lastYear) extend the `from` - // boundary backwards by the shift distance so the prior-period - // slice rides the same fetch and `shiftDailySeriesForward` finds - // its input. - // - // v1.19.0 — "All" range (rangePoints === 0) requests `ALL_RANGE_DAYS` - // (~10 years) rather than the old 365-day cap, which silently truncated - // any account holding more than a year of history. The window is a - // generous fixed bound (not derived per-account) so the cache key stays - // stable across the session. - // - // v1.19.2 — the server reader steps the bucket tier up to WEEK / MONTH - // for windows past the DAY cap, so the "All" tab now returns coverage - // across the whole history (downsampled by tier) rather than the most - // recent ~365 daily buckets. The client folds those coarse buckets and - // `bucketTimeSeries` re-buckets to the visible range as before. - // - // The window ends at the end of TODAY in the profile time zone, not at the - // mount instant: a reading saved after the chart mounted (measuredAt = now) - // has to fall inside the window the invalidated query refetches. `todayKey` - // re-derives on every render, so the window stays put all day (a stable - // cache key) and moves on by itself after midnight. - 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]); - - // v1.18.6 — honour the dashboard's batched slice ONLY for windows over - // 7 days and ONLY when EVERY non-sleep type the chart renders is present - // in the slice. SLEEP_DURATION is never batched (per-night `/series`), - // so a chart that includes it always self-fetches. The 7-day tab - // self-fetches its own window. - // - // v1.19.0 — also require that the requested window FITS within the - // batch's actual coverage. The dashboard batches only a ~30-day slice; - // pre-fix, picking the 90 / All tab kept reading that 30-day slice - // (`usePreloaded` never compared windows), so the chart showed ~30 days - // no matter the tab. The mount now threads `preloadedCoverageDays`; a - // tab wider than the coverage falls through to a real fetch for the - // requested window. Without an explicit coverage (legacy mount), treat - // it as 0 so any preloaded 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 fetch round-trip. - // Keeping the previous range's series visible is only honest because the - // render below pairs it with a visual dimming while `isPlaceholderData` - // is true — the old range must never masquerade as the new one. - placeholderData: keepPreviousData, - // v1.4.40 — route the chart-data key through `queryKeys.chartData` - // so the `["chart-data"]` prefix lands in `measurementDependentKeys` - // and a fresh measurement evicts every per-chart daily cache in one - // pass (audit C2). The factory-packed tuple is byte-identical with - // the pre-v1.4.40 inline literal so the cache layout stays stable. - // v1.15.20 — BMI is a pure transformation of the WEIGHT series - // (value / height²), so the BMI view shares the raw series' cache - // entry instead of re-fetching the same window under its own key. - // The key pins the literal "raw" / "no-bmi" discriminators (the - // factory tuple shape is unchanged) and the BMI division happens in - // the `select` callback below — the factory rule stays intact and - // the cached data is mode-agnostic. - queryKey: queryKeys.chartData( - types.join(","), - "raw", - "no-bmi", - // v1.4.25 — bucket keys + tick labels depend on the active - // user timezone, so re-key the cache when it changes. Without - // this, a tz change inside a session would render stale buckets. - userTimezone, - // v1.4.28 — bound the cache by the active fetch window so - // a range-tab change re-fetches the right slice rather than - // re-running the unbounded walk. - fetchWindow.from, - fetchWindow.to, - // v1.7.0 — re-key when the display scale changes so a km/h chart - // never reads a m/s-scaled sibling out of the cache. - valueScale, - // v1.18.6 — discriminate a batched-slice render from a self-fetch - // ONLY on the dashboard mounts that pass `preloadedSeries`. When the - // prop is absent (insights sub-pages, mini cards) the empty string - // keeps the factory tuple byte-identical with the pre-v1.18.6 - // 8-element key, so those callers' cache layout is unchanged. On a - // dashboard mount the discriminator flips "preloaded" → "fetch" when - // a range-tab change drops batched coverage, re-fetching cleanly. - preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "", - ), - // v1.4.28 — cache the bounded window for a minute so tab - // navigation between insights sub-pages does not re-fire every - // chart's fetch. `gcTime` keeps the inactive cache for five - // minutes so a quick back-and-forth doesn't repay the cost. - staleTime: 60_000, - gcTime: 5 * 60_000, - queryFn: async () => { - const dailyAggregates = new Map< - string, - { - timestamp: number; - values: Record< - string, - { - sum: number; - count: number; - // v1.8.5 — per-bucket spread for the range band. Seeded - // from the rollup daily-aggregate's min / max columns; - // null when the API row carries no spread (raw-row path - // or cumulative metric). - min: number | null; - max: number | null; - } - >; - } - >(); - - // v1.11.5 — SLEEP_DURATION is stored one row per sleep STAGE per night - // (minutes). The generic `/api/measurements` daily-aggregate path - // averages those stage rows, which neither sums a night nor isolates - // the asleep stages — it surfaced a ~2× inflated, mislabeled chart. The - // `/api/measurements/series?kind=sleep` adapter reconstructs ONE point - // per night carrying the night's TIME-ASLEEP in HOURS (CORE + DEEP + REM, - // bare-ASLEEP only when no granular stage exists, IN_BED + AWAKE - // excluded, dual-source nights collapsed to one source). Routing the - // sleep chart through it keeps every sleep surface on the same - // reconstruction. One point per night → `sum/count` yields the night's - // hours unchanged. - async function fetchSleepNights() { - const sleepParams = new URLSearchParams(); - sleepParams.set("kind", "sleep"); - // The series route caps sleep at 365 days regardless; clamp the - // request so a longer comparison window still resolves. - sleepParams.set( - "days", - String( - Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays))), - ), - ); - // v1.16.8 — a failed fetch rejects the whole query instead of - // silently resolving an empty series: the swallowed-catch - // variant cached the empty result as fresh success, so an - // outage painted "no data in this range" with no retry path. - 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); - - // v1.18.6 — when the dashboard handed this chart a batched slice - // for the type, fold those rows instead of a per-type fetch. The - // rows came from `/api/measurements/series-batch`, which reads - // through the SAME rollup path the per-type daily fetch hits, so - // the bucketed result is byte-identical. `usePreloaded` already - // gated the window (`> 7`) + per-type coverage above; default to - // an empty slice (paints the no-data card) rather than fetching, - // so a covered chart never issues a network request. - const page = - usePreloaded && preloadedSeries - ? (preloadedSeries[type] ?? []) - : await (async () => { - // v1.16.8 — a failed per-type fetch rejects the whole - // query instead of silently dropping the series: the - // swallowed-catch variant cached the partial/empty result - // as fresh success, so an outage painted the empty state - // (or a half chart) with no retry path. Rejecting lets - // TanStack's retry semantics and the in-card error state - // below do their jobs. - const data = await apiGet<{ - measurements?: MeasurementApiRow[]; - }>(`/api/measurements?${typeParams}`); - return data?.measurements ?? []; - })(); - - for (const measurement of page) { - // v1.7.0 — fold the display-time scale into the raw value at - // the single read boundary so bucketing, the y-domain, the - // personal baseline, the trend, and the tooltip all operate - // on the scaled series uniformly. `valueScale` defaults to 1 - // (identity), so non-unit-fixed charts read byte-identical. - // v1.15.20 — the BMI division moved into the query's `select` - // callback so the cached series stays raw and the WEIGHT and - // BMI views share one cache entry. - 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; - // v1.8.5 — carry the rollup bucket's min / max through when the - // API supplies them (daily-aggregate path, non-cumulative). - // `valueScale` already folded into `value` above; apply the - // same scale to the spread so the band tracks the line. The - // BMI view drops the range tuple in its `select` callback (it - // has never rendered the band), so the raw cache always - // carries the spread. The 7-day range never drew the band (it - // used to read raw rows, which carry no spread) and still does - // not, so its picture is unchanged now that it reads days too. - 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)) { - // v1.4.29.1 — cumulative HealthKit types (steps, active energy, - // distance, flights, daylight) must reduce with sum, not the - // per-sample average. The server already returns one row per - // day (or, for the coarser tiers of the "All" range, one row per - // week or month carrying its average day); a day that arrives - // as several rows must still add up rather than average. - const isCumulative = CUMULATIVE_HK_TYPES.has( - type as MeasurementType, - ); - point[type] = isCumulative ? stats.sum : stats.sum / stats.count; - // v1.8.5 — emit the day's spread as a `[min, max]` tuple key - // so the range-band can shade it. Only when the - // rollup supplied both bounds and the metric is not - // cumulative (a SUM 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; - }, - // v1.15.20 — BMI view: transform the cached raw WEIGHT series into - // BMI at read time. The transformation lives in `select` (not the - // queryFn) so the cache entry stays raw and is shared with the - // weight chart; tanstack re-runs the projection without a refetch. - // No height on the profile → empty series (matches the previous - // behaviour where every point was skipped). The `${type}__range` - // tuples are dropped — the BMI view has never rendered 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: isAuthenticated, - }); const { data, isLoading, isError, error, isPlaceholderData, refetch } = - chartQuery; - - // v1.16.0 — report the settled data query to the dashboard's shared - // reveal gate. `isLoading` only covers the INITIAL fetch (a later - // range-tab change creates a new cache entry but the gate has long - // latched by then), so this fires exactly when the first paintable - // state — chart, empty-window card, or error fallback — is available. - // - // v1.20.1 — fire exactly once, gated on a ref rather than on the - // `onDataReady` prop identity. The dashboard passes an inline - // `() => markChartReady(id)` closure, so `onDataReady` is a NEW - // reference on every parent render; keying the effect on it re-ran the - // notify on every commit (e.g. a snapshot refetch on tab-resume), and - // that per-commit passive effect kept the chart-row's Radix-Popper - // anchors re-committing until React tripped its update-depth guard - // (#185). The ready signal is monotonic — once a chart has settled it - // stays settled — so latch it behind a ref and depend on `isLoading` - // alone. `onDataReady` is read through a ref so a late-arriving handler - // still fires without re-arming the effect. - 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]); - - // v1.4.6: aggregate to weekly / monthly when the visible range is - // long enough that drawing every daily point would clutter the - // chart. Picking the bucket from the *visible* range, not the - // total dataset, so a "30 days" toggle still shows daily even on - // a five-year-old account. - // - // Held in its own memo so the grain has ONE source. `chartData` bucket - // by it, and the data table below the chart captions its rows with it — - // a table that said "daily" over a weekly mean would be a lie, and - // recomputing the decision separately is how that lie starts. - // - // v1.37.29 — the slice is a CALENDAR-DAY window, matching what the - // range tabs promise and what the fetch window requests. The previous - // `slice(-rangePoints)` was a no-op on the self-fetched path (the fetch - // is already bounded to the window) but silently switched to - // "last N readings" on the dashboard's preloaded ~30-day batch slice — - // the same tab meant two different things depending on the mount path. - 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 (renders as "no data in this - // range") — returning null here would drop chartData back to the - // UNFILTERED series and quietly widen the window instead. - 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; - - const { sliced, bucketType } = visibleSlice; - - 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((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; - }, [data, visibleSlice, showMA, showTrend, types, tzFmt, userTimezone]); - - // v1.12.8 — chart-reactive metric statistics. - // - // Compute the per-type Min / Max / Median / Mean over the data currently - // visible under the active range tab — i.e. the day-window-filtered, - // bucketed `chartData` the chart already holds (no re-fetch). The result - // recomputes whenever the range tab changes the visible slice, so the - // `` a sub-page lifts the callback into always reflects the - // same window the chart paints. Skipped in mini mode (no stat strip). - const visibleStatsByType = useMemo | null>(() => { - if (mini || !chartData?.length) return null; - const out: Record = {}; - 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]); - - // Report the visible-range stats up to the sub-page so the shared - // `` can read them. Effect (not render-time call) so the - // parent state update never fires during this component's render. - // Only once the read has settled: a report during the initial 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]); - - // v1.4.16 — comparison overlay. - // - // When the toggle is active, derive a prior-period series from the - // ALREADY-FETCHED daily aggregates (`data`), shift the timestamps - // forward by 30 / 365 days, and stamp the values into the visible - // `chartData` under `${type}_compare` keys. The Recharts - // children below render those keys as dimmed dashed lines beneath - // the current series, and the tooltip picks them up so the user can - // read both numbers + the delta in one place. - // - // The merge is point-equal-day: a prior-period point only paints - // when its shifted timestamp lands on a visible day. Sparse prior - // periods are silently dropped (the chart's empty caption surfaces - // "Comparison unavailable — no data from last month yet" via the - // hasComparisonData flag below). - const chartDataWithCompare = useMemo(() => { - if (!chartData || effectiveCompareBaseline === "none") return chartData; - if (!data?.length) return chartData; - - const shifted = shiftDailySeriesForward( - data.map((row) => ({ - timestamp: row.timestamp, - values: Object.fromEntries( - types.map((type) => [type, row[type] as number | undefined]), - ), - })), + useHealthChartSeries({ + types, + valueMode, + bmiDivisor, + valueScale, + valueOffset, + userTimezone, + dayKeyFormatter, + tzFmt, + rangePoints, effectiveCompareBaseline, - ); - - // Index shifted rows by the same day-key the chart already uses. - const shiftedByDay = new Map>(); - 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; + preloadedSeries, + preloadedCoverageDays, + enabled: isAuthenticated, + onDataReady, }); - }, [chartData, effectiveCompareBaseline, data, types, tzFmt]); - /** - * v1.4.16 — true when at least one visible day has a prior- - * period value to overlay. Drives the "Comparison unavailable" caption - * fallback in the chart header. Empty input → false → caption shows; - * a partial overlay (some days have prior data, some don't) is treated - * as "available" so we don't surprise the user with a missing caption - * when they can clearly see SOME dimmed history. - */ - 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]); - - // v1.37.29 — the header chip reads the SAME grain decision `chartData` - // and the data table use. This was a second memo re-deriving the bucket - // from a `slice(-rangePoints)` of its own — exactly the "recomputing - // the decision separately" drift the visibleSlice doc-comment warns - // about, and the two had already diverged on the windowing semantics. - 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`)); - // v1.4.16 — extend the y-domain to fit the prior-period - // overlay. Without this the dimmed line can clip outside the - // visible range when last-month / last-year had a different scale. - 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); - }, [ + const { + visibleSlice, + chartData, + visibleStatsByType, chartDataWithCompare, - effectiveCompareBaseline, + hasComparisonData, + activeBucket, + bucketCaption, + yDomain, + } = useHealthChartView({ + data, + rangePoints, + types, + userTimezone, + tzFmt, showMA, showTrend, - types, - ]); + effectiveCompareBaseline, + mini, + isLoading, + isError, + onVisibleStats, + }); - const visibleBands = useMemo(() => { - if (!showBands || !valueBands?.length || !yDomain) return []; + const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } = + useHealthChartOverlays({ + showBands, + showTrend, + valueBands, + targetZones, + yDomain, + chartData, + types, + unit, + valueMode, + t, + fmt, + }); - const [domainMin, domainMax] = yDomain; - const span = domainMax - domainMin; - if (!Number.isFinite(span) || span <= 0) return []; - - return valueBands - .map((band, index) => { - const low = Math.max(Math.min(band.min, band.max), domainMin); - const high = Math.min(Math.max(band.min, band.max), domainMax); - if (high <= low) return null; - - const topPct = ((domainMax - high) / span) * 100; - const heightPct = ((high - low) / span) * 100; - - return { - key: `${band.min}-${band.max}-${index}`, - topPct, - heightPct, - color: band.color, - opacity: band.opacity ?? 0.2, - strokeOpacity: band.strokeOpacity ?? 0.35, - }; - }) - .filter( - ( - entry, - ): entry is { - key: string; - topPct: number; - heightPct: number; - color: string; - opacity: number; - strokeOpacity: number; - } => entry !== null && entry.heightPct > 0, - ); - }, [showBands, valueBands, yDomain]); - - const visibleTargetZones = useMemo(() => { - if (!showBands || !targetZones?.length || !yDomain) return []; - const [domainMin, domainMax] = yDomain; - const span = domainMax - domainMin; - if (!Number.isFinite(span) || span <= 0) return []; - - const zones: Array<{ - key: string; - low: number; - high: number; - topPct: number; - heightPct: number; - color: string; - opacity: number; - label: string | undefined; - textColor: string | undefined; - lineOpacity: number; - }> = []; - - for (const [index, zone] of targetZones.entries()) { - const low = Math.max(Math.min(zone.min, zone.max), domainMin); - const high = Math.min(Math.max(zone.min, zone.max), domainMax); - if (high <= low) continue; - const topPct = ((domainMax - high) / span) * 100; - const heightPct = ((high - low) / span) * 100; - - zones.push({ - key: `${zone.min}-${zone.max}-${index}`, - low, - high, - topPct, - heightPct, - 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]); - - // v1.4.33 — preserve one decimal when the visible Y-domain span is - // narrow enough that two distinct gridlines would otherwise round to - // the same integer (e.g. weight at 82.8 vs 83.5 both rendering as - // "83 kg"). The threshold matches the body-composition cluster: any - // domain spanning < 6 units gets `0.1`-precision ticks; wider domains - // keep the integer formatter so BP / glucose / steps stay clean. + // One decimal on the axis while the visible domain spans under 6 units, so + // two gridlines never round to the same integer (82.8 vs 83.5 kg). const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null; const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6; const formatAxisValue = (value: number) => @@ -1462,214 +233,37 @@ export function HealthChart({ const formatTooltipValue = (value: number) => fmt.number(value, 1); - // Accessibility — a concise spoken summary of the primary series so a screen - // reader announces the shape of the chart (range, latest value, direction) - // instead of an unlabelled graphic. Mirrors the visible-range stats the - // chart already holds; falls back to the raw min/max when the stat strip - // is suppressed (mini mode). - 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, + const { primaryType, chartAriaLabel, dataTableColumns } = + useHealthChartLabels({ + types, + valueMode, + t, + fmt, + chartData, + chartDataWithCompare, + visibleStatsByType, + showDataTable, + mini, }); - }, [ - primaryType, - valueMode, - t, - chartDataWithCompare, - chartData, - visibleStatsByType, - fmt, - ]); - - // Columns for the data table below the chart: one per series the chart - // actually drew a value for, labelled by the SAME helper that names the - // Recharts , so a header and a legend entry can never disagree. A - // type with no value anywhere in the visible window gets no column — a - // column of dashes states nothing the chart's own empty line does not. - const dataTableColumns = useMemo(() => { - 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, - ]); - - 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, - ); - - // v1.4.16 — delegate to the pure helper so the long- - // window split-half delta gets a unit-test that doesn't depend - // on Recharts. See `src/lib/analytics/window-trend.ts`. - 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}` : ""; - - // v1.4.16: when the visible window is long (e.g. "All" - // on a multi-year account), the per-week delta becomes vanishingly - // small and rounds to ±0.0 in the formatter — the maintainer's complaint - // "Wenn ich alle anklicke, dann wird einfach Null Veränderung - // angezeigt" was exactly this: a slope of ~0.5 kg/year prints - // as "+0.0 kg/week" and the user reads it as "no change". For - // windows ≥ SPLIT_HALF_THRESHOLD_DAYS we additionally surface - // the first-half vs. second-half mean delta — a single, - // meaningful number that cannot round to zero unless the metric - // truly didn't 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]); - - // v1.4.16 — personal baseline (90-day rolling median) per type. - // Painted as a faint dashed reference line labelled "Your normal" so - // the user can read each point against their own baseline, not an - // absolute value the chart axis happens to render. - const personalBaselines = useMemo(() => { - if (!chartData?.length) return new Map(); - const map = new Map(); - 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]); - - // v1.4.43 — keep the card shell mounted even when the loaded - // payload is empty. Pre-fix the early `return null` here erased the - // whole widget (header + range tabs + chart) and the user saw an - // unexplained gap on the dashboard. The in-card render branch below - // now paints a "no data in this range" empty state so the user - // understands the chart loaded but the selected window holds no - // measurements — distinct copy from the < 3-points "more days - // needed" hint so the two situations don't blur. + // An empty window keeps the card shell and paints "no data in this range" + // inside it, rather than leaving an unexplained gap. const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1); - // v1.4.20 — derive visible annotations via the pure helper - // (`resolveAnnotationPositions`). Drops anything outside the - // visible window so the chart line + tooltip stay untouched. const annotationPositions = resolveAnnotationPositions( annotations, chartData, ); - - // v1.4.25 — GLP-1 injection-day vertical markers. Pure-helper - // pattern lets the chart-tests pin the marker resolution without - // mounting Recharts. Off-window markers silently drop here so the - // below never paints a line for an out-of-range - // day. const verticalMarkerPositions = resolveVerticalMarkerPositions( verticalMarkers, chartData, ); - // v1.42 — the chart as a door to its days (`dayLinks`). Daily points only: - // a week or month point averages many days and has no one day to open. + // The days as doors: daily points only (a week or month point averages + // many days and has no one day to open). const dayLinksActive = dayLinks && !mini && activeBucket === "day"; - // What the day shows at its top when it is opened from a point: the - // chart's own name and the point's value as the tooltip reads it. + // What the day shows at its top when opened from a point: the chart's name + // and the point's value as the tooltip reads it. const dayFocusFor = (point: ChartDataPoint | undefined) => { if (!point) return null; const parts = types @@ -1705,20 +299,14 @@ export function HealthChart({ const showContextDetails = showMA || showTrend || showBands; const animationsEnabled = !prefersReducedMotion(); + const label = (type: string) => getTypeLabel(type, valueMode, t); - // v1.4.16 — mini mode tunes padding + margins so the chart fits - // inside the rationale card without overwhelming the surrounding - // rows. Range tabs + toggle row are suppressed entirely; the chart - // pins to `windowOverride` (or the default 30pt) instead. + // Mini mode fits the rationale card: tighter padding, no tabs or toggles. const containerClass = mini ? "bg-card border-border rounded-md border p-2" : "bg-card border-border rounded-xl border p-4 md:p-6"; - // v1.4.27 — chart height now reads from a CSS variable - // (`--chart-height`) so consumers can override the default per-mount - // without re-styling the component. The `mini` branch keeps the - // 140 px contract; the regular branch defaults to 240 px on mobile - // and 280 px from `md:` upwards (the wider container can absorb a - // taller chart without crowding the tile strip above). + // The height reads from `--chart-height` so a mount can override it: 140 px + // in mini mode, else 240 px, and 280 px from `md` up. const chartHeightClass = mini ? "h-[var(--chart-height,140px)]" : "h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]"; @@ -1726,128 +314,28 @@ export function HealthChart({ return (
{!mini && ( - /* v1.4.19 — mobile-first header layout. - - Pre-fix: title + bucket chip + comparison chip + 4 range tabs + - cog all sat on a single justify-between flex row. On Pixel 5 - the chips ate enough horizontal space that the range tabs - wrapped to a second row INSIDE that flex (header height - jumped from 44 px → 92 px); on Galaxy Fold compact (280 px) - the tabs split into 3-4 rows. - - Mobile (default): two stacked rows. Title + non-essential - chips on row 1; range tabs + cog right-aligned on row 2. - Bucket-aggregation + comparison chips hide entirely below - `sm:` because they're decorative and the chart's range tabs - already communicate the visible window. The cog dropdown - stays accessible to surface those overlays explicitly. - - ≥sm: original side-by-side layout, all chips visible. */ -
-
- {/* v1.12.8 — when a `titleIcon` is supplied (every Insights - metric subpage), the chart card leads with the canonical - `` (icon + title at `text-base`, foreground - colour, h-5 w-5) so it matches the assessment / target / - stat tiles. The dashboard / ad-hoc mounts omit the icon and - keep the compact heading. - - That compact heading is an `

`, not an `

`. The card is - a sibling of the other cards in the same grid, and those name - themselves at the second level already — the dashboard's - "Recent unlocks" and "Recent workouts" tiles both render - ``, and the metric subpages' target / - usual-range cards are `

` too. Sitting one level lower made - the chart card the odd one out AND skipped a level under the - page `

`: the dashboard's rendered ladder read h1 → h3 → - h3 → h3 → h2 → h2, and /insights/bmi read h1 → h3 → h2. A - heading level is a claim about nesting, and the chart card is - not nested inside anything. */} - {titleIcon ? ( - - ) : ( -

{title}

- )} - {bucketCaption && ( - - {t(bucketCaption)} - - )} - {/* v1.4.16 — comparison caption. Inline with the - bucket-aggregation chip so the user reads "what window - am I looking at" + "what comparison is overlaid" in one - glance. The "Comparison unavailable" fallback uses - neutral muted styling so it doesn't read as an error. - - v1.4.19 — hidden on mobile to free up the title row. */} - {effectiveCompareBaseline !== "none" && hasComparisonData && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.captionLastMonth" - : "comparison.captionLastYear", - )} - - )} - {effectiveCompareBaseline !== "none" && !hasComparisonData && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.unavailable.lastMonth" - : "comparison.unavailable.lastYear", - )} - - )} - {/* v1.16.8 — the in-header "adjust targets" link is gone. - The page-level target-adjust control in the sub-page - header (``) owns the affordance; the - chart card stays a read surface. */} -

-
- { - // v1.12.8 — a range-tab change re-slices `chartData`; the - // visible-range stats memo recomputes off the new slice and - // the stat strip follows automatically. - setRangeHydrated(true); - setRangePoints(days); - // v1.30.1 — persist the pick per chart, same model - // as the overlay toggles, so the range survives a - // remount instead of resetting to 30 d every visit. - if (chartKey && !windowOverride) { - overlayPrefs.setPrefs({ - ...overlayPrefs.prefs, - rangePoints: days, - }); - } - }} - /> - {/* v1.4.18 — overlay-controls dropdown anchored next to - the range tabs. Only painted when the chart is bound - to a persistent chartKey; ad-hoc usages keep the - clean-line default. */} - {chartKey && overlayControls ? ( - - ) : null} -
-
+ { + setRangeHydrated(true); + setRangePoints(days); + // The pick persists per chart, like the overlay toggles, so it + // survives a remount. + if (chartKey && !windowOverride) { + overlayPrefs.setPrefs({ + ...overlayPrefs.prefs, + rangePoints: days, + }); + } + }} + overlayPrefs={overlayPrefs} + showOverlayControls={Boolean(chartKey && overlayControls)} + /> )} {mini && ( @@ -1865,12 +353,8 @@ export function HealthChart({ ) : null} {isLoading ? ( - // v1.16.0 — height-matched skeleton band instead of the former - // `h-48` spinner box. The loading state now occupies the exact - // box the painted chart will (same `chartHeightClass`), so the - // card height never jumps when the data lands — the dashboard's - // shared-reveal overlay and every insights mount stay - // layout-shift-free. + // A skeleton in the exact box the painted chart takes, so the card + // height never jumps when the data lands. <> {mini ? null : ( @@ -1881,10 +365,8 @@ export function HealthChart({ )} ) : isError ? ( - // v1.16.8 — a failed query paints as an ERROR with a retry - // affordance, not as the "no data in this range" empty state. - // Pre-fix `isError` fell through to the empty branch and an - // outage read as "you have no measurements". + // A failed read is an error with retry, never "no data in this + // range": an outage must not read as "you have no measurements". void refetch()} /> ) : !chartData?.length ? ( - // v1.4.43 — empty-window state. - // - // The chart only withholds rendering when the selected range - // holds zero daily points. Any real data — even a single day — - // renders the points below; a sparse-data caption (rather than - // a withholding card) explains that more days fill out the - // trend. The genuinely-empty window paints a distinct empty - // state so the user reaches for the range tabs or the quick-add - // instead of suspecting a broken widget. + // Only a window with no daily point at all withholds the plot; any + // real data, even one day, is drawn (with the sparse caption below).
{visibleBands.length > 0 ? ( - // v1.4.27 — band overlay positioning fix. The - // overlay used to inset `right: 18px` while the chart - // margin is `right: 8`, so the band rectangle drifted left - // of the plotted line by 10 px on every viewport. Pin the - // overlay to the same right edge the ComposedChart uses so - // the band tracks the line exactly. -
- {visibleBands.map((band) => ( -
- ))} -
+ ) : null}
- {visibleTargetZones.map((zone) => ( - - ))} - {visibleTargetZones.map((zone) => ( - - ))} - {visibleTargetZones.map((zone) => ( - - ))} - {/* v1.4.20 — storyboard annotations. - Vertical reference lines pinned to a specific - visible pointIndex; the label sits above with a - short truncation on `=sm. Annotations off-window silently - drop via `ifOverflow="discard"`. */} - {annotationPositions.map((annotation, i) => ( - - ))} - {/* v1.4.25 — GLP-1 injection-day vertical markers. - Thin dashed line + a small filled dot at the x-axis - intersection so a row of markers reads as "injection - cadence" without crowding the canvas. Optional label - rendered only when the caller passed one; the - dashboard tile leaves it undefined (the date is - redundant — the chart's x-axis already shows it). */} - {verticalMarkerPositions.map((marker, i) => ( - - ))} - {yDomain && - verticalMarkerPositions.map((marker, i) => ( - - ))} - {/* v1.42 — 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. */} - {openDayIndex !== undefined ? ( - - ) : null} - {openDayIndex !== undefined && openDayPoint - ? types.map((type, i) => { - const v = openDayPoint[type]; - if (typeof v !== "number") return null; - return ( - - ); - }) - : null} - {/* v1.4.18 — personal-baseline reference line is now - opt-in via the Trend toggle. the maintainer rejected the - always-on dashed mean line; it now only paints when - the user actively shows the trend overlay (matching - his rule: "only when a trend is being displayed"). - 90-day rolling median per type, faint dashed line, - only the first type gets the inline label so - multi-type charts don't paint duplicate labels. */} - {showTrend && - types.map((type, i) => { - const baseline = personalBaselines.get(type); - if (baseline == null) return null; - return ( - - ); - })} + {targetZoneLayers(visibleTargetZones, maxPointIndex)} + {annotationLayers(annotationPositions, viewportWidth)} + {verticalMarkerLayers(verticalMarkerPositions, yDomain)} + {openDayLayers(openDayIndex, openDayPoint, types, colors)} + {baselineLayers( + showTrend, + types, + colors, + personalBaselines, + t, + )} { - const { - active, - payload, - label: rechartsLabel, - } = props as unknown as { - active?: boolean; - payload?: Array<{ - name?: string; - value?: number; - color?: string; - dataKey?: string; - payload?: ChartDataPoint; - }>; - 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 ? tzFmt.date(new Date(ts)) : ""; - const rows: RichTooltipRow[] = []; - // Build a quick lookup of compare values for this - // hover-day so the current-period row can attach - // a "vs. last month / year" delta inline. Same-day - // current ↔ compare values 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 ?? ""); - // Skip auxiliary lines (`*_ma`, `*_trend`, - // `*_compare`) — the comparison value is rendered - // inline as the delta on the current-period row, - // and ma / trend already appear as dashed overlays - // on the chart itself. - if ( - dataKey.endsWith("_ma") || - dataKey.endsWith("_trend") || - dataKey.endsWith("_compare") - ) - continue; - const baseline = personalBaselines.get(dataKey); - let delta: string | undefined; - // v1.4.16 — prefer the comparison delta - // ("Δ −7 vs. last month") over the personal - // baseline delta when comparison is active and we - // have a prior value for this day. Otherwise fall - // back to the existing baseline-delta path. - const compareValue = - effectiveCompareBaseline !== "none" && hoverPoint - ? (hoverPoint[`${dataKey}_compare`] as - number | undefined) - : undefined; - if ( - effectiveCompareBaseline !== "none" && - typeof compareValue === "number" && - Number.isFinite(compareValue) - ) { - const diff = item.value - compareValue; - if (Math.abs(diff) < 0.05) { - delta = t("charts.deltaUnchanged"); - } else { - const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${fmt.number( - Math.abs(diff), - 1, - )}${unit ? ` ${unit}` : ""}`; - delta = t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.deltaVs.lastMonth" - : "comparison.deltaVs.lastYear", - ).replace("{delta}", formatted); - } - } else if (baseline != null) { - const diff = item.value - baseline; - if (Math.abs(diff) < 0.05) { - delta = t("charts.deltaUnchanged"); - } else { - const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${fmt.number( - Math.abs(diff), - 1, - )}${unit ? ` ${unit}` : ""}`; - delta = t("charts.deltaVsBaseline").replace( - "{delta}", - formatted, - ); - } - } - 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: `${formatTooltipValue(item.value)}${ - unit ? ` ${unit}` : "" - }`, - color: item.color ?? "var(--dracula-purple)", - delta, - }); - // v1.4.16 — also surface the prior-period - // value as its own row so the user reads both - // numbers (current AND last-month / last-year) - // alongside the delta. - if ( - effectiveCompareBaseline !== "none" && - typeof compareValue === "number" && - Number.isFinite(compareValue) - ) { - rows.push({ - name: `${item.name ?? dataKey} · ${t( - "comparison.tooltipPrior", - )}`, - value: `${formatTooltipValue(compareValue)}${ - unit ? ` ${unit}` : "" - }`, - color: item.color ?? "var(--dracula-purple)", - }); - } - } - if (rows.length === 0) return null; - return ( - - ); - }} + content={makeTooltipContent({ + chartData, + dayLinkSource, + personalBaselines, + compareBaseline: effectiveCompareBaseline, + bucketCaption, + unit, + t, + fmt, + dateLabelFor: tzFmt.date, + formatValue: formatTooltipValue, + tooltipAction: chartDays.tooltipAction, + })} /> {showContextDetails && ( )} - {/* v1.8.5 — min–max range band. An Apple-Health-style - shaded area between each day's min and max, painted - behind the mean line so it reads as context, not a - second series. Gated on `!mini` (the dashboard - sparkline stays clean) and on the daily-aggregate - path supplying a `[min, max]` tuple per point — single - non-cumulative metrics with intra-day spread (pulse, - weight, glucose, BP). The toggle rides the existing - target-range pref so the user controls both bands - from one switch. */} - {!mini && - showBands && - types.map((type, i) => ( - - ))} - {types.map((type, i) => ( - - ))} - {showMA && - types.map((type, i) => ( - - ))} - {showTrend && - types.map((type) => ( - - ))} - {/* v1.4.16 — comparison overlay. - A dimmed dashed line per type for the prior period - (lastMonth / lastYear), painted BENEATH the current - series via Recharts' source-order layering. Same - base colour as the current line but with reduced - stroke opacity (45%) and a thinner stroke (1.25) - so the user reads the current line first and the - overlay as orientation. */} - {effectiveCompareBaseline !== "none" && - hasComparisonData && - types.map((type, i) => ( - - ))} + {seriesLayers({ + types, + colors, + label, + t, + mini, + showBands, + showMA, + showTrend, + compareBaseline: effectiveCompareBaseline, + hasComparisonData, + animationsEnabled, + })}
- {/* Sparse-data caption. With fewer than three daily points the - chart still paints every reading (a single marker for one - day, a line for two), and this subtle note sets the - expectation that more days fill out the trend — rather than - withholding the data behind a placeholder card. */} + {/* With fewer than three daily points the chart still paints + every reading; this note says more days fill out the trend. */} {!mini && (chartData?.length ?? 0) < 3 ? (

) : null} - {/* The points the chart just drew, as a table. Reads the SAME - expression `` is handed above, so the two - cannot disagree. Sits inside this branch on purpose: an empty - window paints the empty state and no table at all, because - `` already says there is nothing here and a - disclosure opening onto nothing would say it twice. */} + {/* The points the chart just drew, as a table, read from the same + expression `` is handed. Inside this branch + on purpose: an empty window shows the empty state and no table. */} {showDataTable && !mini && dataTableColumns.length > 0 ? ( + {bands.map((band) => ( +

+ ))} +
+ ); +} diff --git a/src/components/charts/health-chart/chart-header.tsx b/src/components/charts/health-chart/chart-header.tsx new file mode 100644 index 000000000..8ee64d7fc --- /dev/null +++ b/src/components/charts/health-chart/chart-header.tsx @@ -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; + showOverlayControls: boolean; +}) { + const { t } = useTranslations(); + return ( +
+
+ {/* With a `titleIcon` (every metric page) the card leads with the + canonical ``; the dashboard and ad-hoc mounts keep the + compact heading. That heading is an `

`: 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 + `

`. */} + {titleIcon ? ( + + ) : ( +

{title}

+ )} + {bucketCaption && ( + + {t(bucketCaption)} + + )} + {/* 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 && ( + + {t( + compareBaseline === "lastMonth" + ? "comparison.captionLastMonth" + : "comparison.captionLastYear", + )} + + )} + {compareBaseline !== "none" && !hasComparisonData && ( + + {t( + compareBaseline === "lastMonth" + ? "comparison.unavailable.lastMonth" + : "comparison.unavailable.lastYear", + )} + + )} + {/* The page-level target control owns "adjust targets"; the chart + card stays a read surface. */} +

+
+ + {/* Only a chart bound to a persistent key offers the overlay + controls; ad-hoc charts keep the clean line. */} + {showOverlayControls ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/charts/health-chart/chart-layers.tsx b/src/components/charts/health-chart/chart-layers.tsx new file mode 100644 index 000000000..b17f2acb0 --- /dev/null +++ b/src/components/charts/health-chart/chart-layers.tsx @@ -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) => ( + + )), + zones.map((zone) => ( + + )), + zones.map((zone) => ( + + )), + ]; +} + +/** + * 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) => ( + + )); +} + +/** + * 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) => ( + + )), + yDomain && + markers.map((marker, i) => ( + + )), + ]; +} + +/** + * 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 ? ( + + ) : null, + openDayIndex !== undefined && openDayPoint + ? types.map((type, i) => { + const v = openDayPoint[type]; + if (typeof v !== "number") return null; + return ( + + ); + }) + : 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, + t: Translate, +) { + return ( + showTrend && + types.map((type, i) => { + const baseline = personalBaselines.get(type); + if (baseline == null) return null; + return ( + + ); + }) + ); +} + +/** + * 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) => ( + + )), + types.map((type, i) => ( + + )), + showMA && + types.map((type, i) => ( + + )), + showTrend && + types.map((type) => ( + + )), + compareBaseline !== "none" && + hasComparisonData && + types.map((type, i) => ( + + )), + ]; +} diff --git a/src/components/charts/health-chart/chart-tooltip-content.tsx b/src/components/charts/health-chart/chart-tooltip-content.tsx new file mode 100644 index 000000000..b2ebb84ad --- /dev/null +++ b/src/components/charts/health-chart/chart-tooltip-content.tsx @@ -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; + +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; + compareBaseline: ComparisonBaseline; + bucketCaption: string | null; + unit?: string; + t: Translate; + fmt: ReturnType; + 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 ( + + ); + }; +} diff --git a/src/components/charts/health-chart/series-math.ts b/src/components/charts/health-chart/series-math.ts new file mode 100644 index 000000000..b975c1ee5 --- /dev/null +++ b/src/components/charts/health-chart/series-math.ts @@ -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 = { + 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(); + 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; +} diff --git a/src/components/charts/health-chart/types.ts b/src/components/charts/health-chart/types.ts new file mode 100644 index 000000000..db6fef62e --- /dev/null +++ b/src/components/charts/health-chart/types.ts @@ -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 | null) => void; + /** + * Leading glyph for the card's ``. 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; + /** + * 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; +} diff --git a/src/components/charts/health-chart/use-health-chart-overlays.ts b/src/components/charts/health-chart/use-health-chart-overlays.ts new file mode 100644 index 000000000..3d23fefd9 --- /dev/null +++ b/src/components/charts/health-chart/use-health-chart-overlays.ts @@ -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; +type Formatters = ReturnType; + +/** 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(() => { + 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(() => { + 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(); + const map = new Map(); + 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 | 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(() => { + 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 }; +} diff --git a/src/components/charts/health-chart/use-health-chart-series.ts b/src/components/charts/health-chart/use-health-chart-series.ts new file mode 100644 index 000000000..a4b7b5b48 --- /dev/null +++ b/src/components/charts/health-chart/use-health-chart-series.ts @@ -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; + usePreloaded: boolean; + preloadedSeries?: Record; + valueScale: number; + valueOffset: number; + dayKeyFormatter: Intl.DateTimeFormat; + tzFmt: ReturnType; +}): Promise { + 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; + rangePoints: number; + effectiveCompareBaseline: ComparisonBaseline; + preloadedSeries?: Record; + 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; +} diff --git a/src/components/charts/health-chart/use-health-chart-view.ts b/src/components/charts/health-chart/use-health-chart-view.ts new file mode 100644 index 000000000..cafb4a2cc --- /dev/null +++ b/src/components/charts/health-chart/use-health-chart-view.ts @@ -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; + 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((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; + baseline: Exclude; + }, +): 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>(); + 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; + showMA: boolean; + showTrend: boolean; + effectiveCompareBaseline: ComparisonBaseline; + mini: boolean; + isLoading: boolean; + isError: boolean; + onVisibleStats?: (stats: Record | 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 | null>(() => { + if (mini || !chartData?.length) return null; + const out: Record = {}; + 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, + }; +} diff --git a/src/components/dashboard/__tests__/tile-destinations.test.ts b/src/components/dashboard/__tests__/tile-destinations.test.ts index 2693972f3..7d2224258 100644 --- a/src/components/dashboard/__tests__/tile-destinations.test.ts +++ b/src/components/dashboard/__tests__/tile-destinations.test.ts @@ -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> = {}; -/** 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, { + 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(); 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-]+"/); }); diff --git a/src/components/dashboard/tiles/chart-tiles.tsx b/src/components/dashboard/tiles/chart-tiles.tsx new file mode 100644 index 000000000..bf05e10bd --- /dev/null +++ b/src/components/dashboard/tiles/chart-tiles.tsx @@ -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 `` 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: () => }, +); +const MoodChart = (props: ComponentProps) => ( + + + +); +const MedicationComplianceChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.MedicationComplianceChart }), + ), + { ssr: false, loading: () => }, +); +const MedicationComplianceChart = ( + props: ComponentProps, +) => ( + + + +); + +// 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 | 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, + "onDataReady" | "compareBaseline" | "dayLinks" + >, + { batched = true, timezone = true } = {}, +): ReactNode { + return ( + 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) => ( + 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) => ( + 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: () => , + }, + { + // 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: () => , + }, + { + // 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: () => , + }, +]; + +/** 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; +} diff --git a/src/components/dashboard/tiles/dashboard-tiles.tsx b/src/components/dashboard/tiles/dashboard-tiles.tsx new file mode 100644 index 000000000..7baf6c8dc --- /dev/null +++ b/src/components/dashboard/tiles/dashboard-tiles.tsx @@ -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 ( + } + title={t("dashboard.emptyTitle")} + description={t("dashboard.emptyDescription")} + action={ + canAddMeasurement ? ( + + ) : undefined + } + /> + ); + } + + return ( + <> + {showTileStripSkeleton && ( + + )} + {stripEntries.length > 0 && ( +
+ {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 = ( + }>{entry.node} + ); + // 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 ? ( + + {cell} + + ) : ( +
+ {cell} +
+ ); + })} +
+ )} + {chartEntries.map((entry) => ( +
+ {/* 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 ? ( + + }>{entry.node} + {entry.count != null ? : null} + + ) : ( + <> + }>{entry.node} + {entry.count != null ? : null} + + )} +
+ ))} + {chartRowPlaceholderCount > 0 && ( + + )} + + ); +} diff --git a/src/components/dashboard/tiles/strip-tiles.tsx b/src/components/dashboard/tiles/strip-tiles.tsx new file mode 100644 index 000000000..d45321c39 --- /dev/null +++ b/src/components/dashboard/tiles/strip-tiles.tsx @@ -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[string]; +type TrafficRange = Parameters[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 ( + + ); +} + +/** 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[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 ? ( + + ) : 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: ( + + ), + }, + ]; + }, + }, + { + // 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: ( + + ), + }; + }), + }, +]; + +/** + * 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; +} diff --git a/src/components/dashboard/tiles/tile-context.ts b/src/components/dashboard/tiles/tile-context.ts new file mode 100644 index 000000000..5838999f5 --- /dev/null +++ b/src/components/dashboard/tiles/tile-context.ts @@ -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 + * ``; 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; diff --git a/src/components/dashboard/tiles/use-dashboard-data.ts b/src/components/dashboard/tiles/use-dashboard-data.ts new file mode 100644 index 000000000..21bbd3ef7 --- /dev/null +++ b/src/components/dashboard/tiles/use-dashboard-data.ts @@ -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["data"], + slim: Parameters[0], + thick: Parameters[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 | 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 | 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; + }>("/api/user/thresholds"); + }, + enabled: !snapshotEnabled && isAuthenticated, + ...DASHBOARD_QUERY_OPTS, + }); + + const { data: layoutDataLegacy } = useQuery({ + queryKey: queryKeys.dashboardWidgets(), + queryFn: async () => { + return apiGet("/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("/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; + }>(`/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; diff --git a/src/components/day/__tests__/day-door-coverage-guard.test.ts b/src/components/day/__tests__/day-door-coverage-guard.test.ts index c820c1b3b..10bf7a177 100644 --- a/src/components/day/__tests__/day-door-coverage-guard.test.ts +++ b/src/components/day/__tests__/day-door-coverage-guard.test.ts @@ -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, +): Map { + const folded = new Map(); + 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 `` 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", ""], + [ + "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( ` s > 1} dayLinks />`, diff --git a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts index 626fc38a0..1e1612aa6 100644 --- a/src/components/documents/__tests__/document-coach-launch-wiring.test.ts +++ b/src/components/documents/__tests__/document-coach-launch-wiring.test.ts @@ -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"); diff --git a/src/components/documents/__tests__/document-detail-download.test.ts b/src/components/documents/__tests__/document-detail-download.test.ts index b59f64e1a..39b0138ae 100644 --- a/src/components/documents/__tests__/document-detail-download.test.ts +++ b/src/components/documents/__tests__/document-detail-download.test.ts @@ -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", () => { diff --git a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts index abfde0ac0..edb6fef15 100644 --- a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts +++ b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts @@ -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", () => { diff --git a/src/components/documents/__tests__/documents-view-navigation.test.ts b/src/components/documents/__tests__/documents-view-navigation.test.ts index ab96a78a8..075d8c08e 100644 --- a/src/components/documents/__tests__/documents-view-navigation.test.ts +++ b/src/components/documents/__tests__/documents-view-navigation.test.ts @@ -8,7 +8,7 @@ import { documentSelectionHref, documentSelectionSurvivedClose, withoutDocumentSelectionHref, -} from "../documents-view"; +} from "../vault/selection-history"; class MemoryHistory { private entries: { href: string; state: unknown }[]; diff --git a/src/components/documents/detail/detail-sections.tsx b/src/components/documents/detail/detail-sections.tsx new file mode 100644 index 000000000..c3d56a28a --- /dev/null +++ b/src/components/documents/detail/detail-sections.tsx @@ -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 ( +
+ {/* .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 ? ( + + ) : ( + + )} +
+ {/* .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. + + ) : null} + {canManageDocuments && ( + + )} + {doc.servingClass === "inline" ? ( + + ) : null} + +
+
+ ); +} + +/** 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 ( + <> +
+ + {editingTitle ? ( + 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 ? ( +

+ {doc.title ?? t("documents.detail.titlePlaceholder")} +

+ ) : ( + + )} +
+ +
+
+ + {/* 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. */} + +
+
+ + + patch.mutate({ + documentDate: value === "" ? null : value, + }) + } + aria-label={t("documents.detail.dateLabel")} + /> +
+
+ +
+

+ {t("documents.detail.conditionsLabel")} +

+
+ {doc.conditionLinks.map((link) => ( + + {link.name} + + ))} + {doc.conditionLinks.length === 0 ? ( + + {t("documents.detail.noConditions")} + + ) : null} + {canManageDocuments && (episodes.data?.length ?? 0) > 0 ? ( + + + + + +
    + {episodes.data?.map((episode) => { + const linked = doc.conditionLinks.some( + (l) => l.episodeId === episode.id, + ); + return ( +
  • + +
  • + ); + })} +
+
+
+ ) : null} +
+
+ + ); +} + +/** + * 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 ? ( + +