From e25b6365fd4159013285ed81d9b7465da2bd052e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:06:32 +0200 Subject: [PATCH 01/35] refactor(dashboard): compose the page from a tile registry The dashboard page held every tile and chart card in one 2,100-line function: a 1,279-line inline block pushed 24 cards behind 36 show flags. The cards now live in components/dashboard/tiles/, each with its layout widget, its own visibility rule (data floor, module, resolved layout) and its render; the layout toggle and order are applied once by the registry. - use-dashboard-data: the snapshot or legacy reads, the layout, mood, thresholds, the batched chart series and the loading gate - tile-context: summaries, display units, comparison deltas, freshness, directions and target bands, derived once per render - strip-tiles / chart-tiles: the two registries and their builders - dashboard-tiles: the strip, the chart row, their skeletons and the empty state The page is a composer of about 170 lines. Rendering is unchanged. The guards that read page-client.tsx read the whole registry through one helper; the weight-direction, HRV freshness, BP-in-target comparison and chart-reveal checks now build the real tiles instead of matching text. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 6 +- .../baselines/test-source-reads.json | 10 +- .../baselines/version-stamp-comments.json | 5 +- ...oard-tile-eligibility-bucket-guard.test.ts | 1 + .../display-transform-rounding-guard.test.ts | 19 +- src/__tests__/helpers/dashboard-source.ts | 37 + .../resolved-field-consumer-guard.test.ts | 5 +- .../unit-preference-display-guard.test.ts | 3 + .../weight-tile-direction-guard.test.ts | 83 +- .../dashboard-analytics-split.test.ts | 13 +- .../__tests__/dashboard-chart-reveal.test.tsx | 89 +- .../dashboard-demo-checklist.test.ts | 4 +- .../__tests__/dashboard-hero-wiring.test.ts | 6 +- .../dashboard-hrv-rmssd-fallback.test.tsx | 49 +- .../dashboard-suspense-boundaries.test.ts | 30 +- .../dashboard-tile-strip-skeleton.test.ts | 26 +- src/app/__tests__/insights-polish.test.ts | 49 +- .../root-loading-silhouette.test.tsx | 6 +- src/app/page-client.tsx | 2327 +---------------- .../__tests__/tile-destinations.test.ts | 31 +- .../dashboard/tiles/chart-tiles.tsx | 386 +++ .../dashboard/tiles/dashboard-tiles.tsx | 196 ++ .../dashboard/tiles/strip-tiles.tsx | 657 +++++ .../dashboard/tiles/tile-context.ts | 267 ++ .../dashboard/tiles/use-dashboard-data.ts | 288 ++ ...board-layout-snapshot-invalidation.test.ts | 6 +- 27 files changed, 2179 insertions(+), 2425 deletions(-) create mode 100644 src/__tests__/helpers/dashboard-source.ts create mode 100644 src/components/dashboard/tiles/chart-tiles.tsx create mode 100644 src/components/dashboard/tiles/dashboard-tiles.tsx create mode 100644 src/components/dashboard/tiles/strip-tiles.tsx create mode 100644 src/components/dashboard/tiles/tile-context.ts create mode 100644 src/components/dashboard/tiles/use-dashboard-data.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index ae77487b9..6bed4cb53 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 121, - "total": 143770, + "count": 120, + "total": 141446, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -12,7 +12,6 @@ "app/api/medications/intake/bulk/route.ts": 1044, "app/api/workouts/batch/route.ts": 958, "app/medications/page-client.tsx": 839, - "app/page-client.tsx": 2324, "components/admin/backups-section.tsx": 1462, "components/charts/health-chart.tsx": 2517, "components/charts/mood-chart.tsx": 1166, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd571a095..fef534c35 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 410, - "total": 147795, + "count": 408, + "total": 144385, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -76,8 +76,6 @@ "app/labs/page.tsx :: LabsPage": 228, "app/medications/page-client.tsx :: MedicationsPageClient": 590, "app/notifications/page.tsx :: NotificationsPage": 327, - "app/page-client.tsx :: DashboardPageClient": 2131, - "app/page-client.tsx :: DashboardPageClient > (anonymous)": 1279, "components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470, "components/admin/admin-shell.tsx :: AdminShell": 254, "components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212, diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 2aff0e4d8..97d3ca3a0 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 367, - "total": 367, + "count": 361, + "total": 361, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -143,18 +143,13 @@ "__tests__/utc-day-key-allowlist-guard.test.ts": 1, "__tests__/v1341-locale-parity.test.ts": 1, "__tests__/visit-link-guard.test.ts": 1, - "__tests__/weight-tile-direction-guard.test.ts": 1, "__tests__/workout-canonical-identity-guard.test.ts": 1, "__tests__/write-rate-limit-guard.test.ts": 1, "__tests__/written-outcome-response-consumer-guard.test.ts": 1, "__tests__/zod-refusal-issue-list-guard.test.ts": 1, - "app/__tests__/dashboard-analytics-split.test.ts": 1, - "app/__tests__/dashboard-chart-reveal.test.tsx": 1, "app/__tests__/dashboard-demo-checklist.test.ts": 1, "app/__tests__/dashboard-hero-wiring.test.ts": 1, "app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx": 1, - "app/__tests__/dashboard-suspense-boundaries.test.ts": 1, - "app/__tests__/dashboard-tile-strip-skeleton.test.ts": 1, "app/__tests__/i18n-boot-script-strategy.test.ts": 1, "app/__tests__/insights-b3-wiring.test.ts": 1, "app/__tests__/insights-polish.test.ts": 1, @@ -192,7 +187,6 @@ "components/charts/__tests__/touch-action-guard.test.ts": 1, "components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx": 1, "components/dashboard/__tests__/medication-intake-quick-add.test.tsx": 1, - "components/dashboard/__tests__/tile-destinations.test.ts": 1, "components/day/__tests__/day-door-coverage-guard.test.ts": 1, "components/day/__tests__/day-not-here-guard.test.ts": 1, "components/documents/__tests__/document-coach-launch-wiring.test.ts": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 65ad892a5..9adcf8e40 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2954, - "total": 10499, + "count": 2953, + "total": 10417, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -678,7 +678,6 @@ "app/onboarding/[step]/page.tsx": 1, "app/onboarding/handover/page.tsx": 1, "app/onboarding/page.tsx": 2, - "app/page-client.tsx": 81, "app/privacy/__tests__/page.test.tsx": 1, "app/privacy/page.tsx": 1, "app/settings/[section]/page.tsx": 1, diff --git a/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts index 2fc4e3a07..64344ca4d 100644 --- a/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts +++ b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts @@ -46,6 +46,7 @@ const WRITTEN_REASON = /tile-eligibility-exempt:\s*\S/; const SURFACES = [ "src/app/page-client.tsx", ...listSourceFiles("src/components/dashboard"), + ...listSourceFiles("src/components/dashboard/tiles"), ...listSourceFiles("src/lib/dashboard"), ]; diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index 470ed94b8..be6777ef7 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -59,7 +59,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; * screen unrounded. */ const RAW_SCALE_SURFACES = [ - "app/page-client.tsx", + // The dashboard's chart cards; the values the dashboard renders itself + // convert in the tile context (see `CONVERTS_IN`). + "components/dashboard/tiles/chart-tiles.tsx", // The insights trends row: a single transformed series hands its scale // and offset to the mini chart, which formats every value itself. "components/insights/trends-row.tsx", @@ -68,6 +70,16 @@ const RAW_SCALE_SURFACES = [ "components/insights/device-score-tile.tsx", ]; +/** + * A surface whose rendered values convert in a sibling module: the dashboard's + * chart cards read the preference through the tile context every dashboard + * tile is built from. + */ +const CONVERTS_IN: Readonly> = { + "components/dashboard/tiles/chart-tiles.tsx": + "components/dashboard/tiles/tile-context.ts", +}; + /** Matches a `factor` / `offset` read off a display transform. */ const RAW_SCALE_READ = /\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/; @@ -207,7 +219,10 @@ describe("display-transform rounding guard — the bypass freeze", () => { // `useUnitDisplayOnceMounted()`, which is the same sugar with the // preference withheld from the hydration render. const offenders = RAW_SCALE_SURFACES.filter( - (rel) => !/\buseUnitDisplay(OnceMounted)?\(\)/.test(read(rel)), + (rel) => + !/\buseUnitDisplay(OnceMounted)?\(\)/.test( + read(CONVERTS_IN[rel] ?? rel), + ), ); expect(offenders).toEqual([]); }); diff --git a/src/__tests__/helpers/dashboard-source.ts b/src/__tests__/helpers/dashboard-source.ts new file mode 100644 index 000000000..18f2def65 --- /dev/null +++ b/src/__tests__/helpers/dashboard-source.ts @@ -0,0 +1,37 @@ +/** + * The dashboard's source, for the guards that pin how it is wired. + * + * The dashboard is a composer (`src/app/page-client.tsx`) over a tile + * registry (`src/components/dashboard/tiles/`): the reads, the tile context, + * the strip and chart definitions and the rendering of both rows each live in + * their own module there. A guard that used to read the one page file reads + * all of them through here, so a rule about the dashboard holds wherever in + * the registry the code it watches sits, and moving code between the files + * cannot turn a negative assertion green. The folder read carries a floor, + * and the page read an anchor, so a rename fails loudly instead of returning + * less text. + */ +import { readSource, readSourceFolder } from "./source-files"; + +export const DASHBOARD_PAGE = "src/app/page-client.tsx"; +export const DASHBOARD_TILES_DIR = "src/components/dashboard/tiles"; + +/** Each dashboard module as `[repo-relative path, text]`. */ +export function dashboardSourceFiles(): Array<[string, string]> { + const page = readSource(DASHBOARD_PAGE, "DashboardPageClient"); + const tiles = readSourceFolder(DASHBOARD_TILES_DIR, { + floor: 5, + include: (rel) => !rel.includes("__tests__"), + }).map(([rel, text]): [string, string] => [ + `${DASHBOARD_TILES_DIR}/${rel}`, + text, + ]); + return [[DASHBOARD_PAGE, page], ...tiles]; +} + +/** The dashboard modules' text, concatenated in a stable order. */ +export function dashboardSource(): string { + return dashboardSourceFiles() + .map(([, text]) => text) + .join("\n"); +} diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index 8fda69874..111d0a380 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -346,7 +346,10 @@ const V143_FIELDS: ResolvedFieldEntry[] = [ field: "GET /api/dashboard/snapshot tiles.directions", props: ["insights/schemas.ts#directions"], readers: [ - { file: "src/app/page-client.tsx", pattern: /tiles\.directions/ }, + { + file: "src/components/dashboard/tiles/tile-context.ts", + pattern: /tiles\.directions/, + }, ], }, { diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index df2d41130..fbc72f0df 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -85,6 +85,9 @@ const HEIGHT_CONTROL = join( function sweptFiles(): string[] { return [ join(SRC, "app", "page-client.tsx"), + ...walkSourceFiles(join(SRC, "components", "dashboard", "tiles"), { + floor: 5, + }).map((rel) => join(SRC, "components", "dashboard", "tiles", rel)), ...walkSourceFiles(SRC, { floor: 700, extensions: [".tsx"] }) .filter( (rel) => rel.startsWith("app/insights/") && rel.endsWith("/page.tsx"), diff --git a/src/__tests__/weight-tile-direction-guard.test.ts b/src/__tests__/weight-tile-direction-guard.test.ts index 7f246b7c3..e706d6125 100644 --- a/src/__tests__/weight-tile-direction-guard.test.ts +++ b/src/__tests__/weight-tile-direction-guard.test.ts @@ -2,67 +2,54 @@ * The dashboard weight tile colours its trend by the resolved target * direction, never by a literal (#1006). * - * Every other tile on the strip passes a literal `directionSentiment`, because - * for resting pulse or steps the good direction is a property of the metric. + * For resting pulse or steps the good direction is a property of the metric. * Weight is the exception: which way is progress depends on the person's own * target, and it is resolved on the server (`tiles.weightTrend` on the - * snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` here is - * exactly the defect the issue reported — a person below their target saw - * every gain coloured as a setback — and it would type-check and render - * without complaint, so this pins the attribute rather than the pixels. + * snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` is + * exactly the defect the issue reported (a person below their target saw + * every gain coloured as a setback), and it would type-check and render + * without complaint. * - * Structural rather than rendered because the dashboard page early-returns a - * loading gate under SSR, and the property is about the source: the weight - * `` names `weightTrend.direction`, or it does not. + * The tile is built from the registry with each resolved direction in turn, + * and the card has to carry the one it was handed. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import ts from "typescript"; +import type { ReactElement } from "react"; import { describe, expect, it } from "vitest"; -const PAGE = join(process.cwd(), "src/app/page-client.tsx"); +import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles"; +import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context"; -function weightTileDirections(): string[] { - const text = readFileSync(PAGE, "utf8"); - const file = ts.createSourceFile( - PAGE, - text, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX, - ); - const found: string[] = []; - const visit = (node: ts.Node) => { - if ( - ts.isJsxSelfClosingElement(node) && - node.tagName.getText(file) === "TrendCard" - ) { - const attrs = node.attributes.properties.filter(ts.isJsxAttribute); - const key = attrs.find((a) => a.name.getText(file) === "key"); - const isWeight = - key?.initializer && - ts.isStringLiteral(key.initializer) && - key.initializer.text === "weight"; - if (isWeight) { - const direction = attrs.find( - (a) => a.name.getText(file) === "directionSentiment", - ); - found.push(direction?.initializer?.getText(file) ?? ""); - } - } - ts.forEachChild(node, visit); - }; - visit(file); - return found; +function weightCards(direction: string) { + const ctx = { + t: (key: string) => key, + fmt: { number: (v: number) => String(v) }, + td: (_type: string, v: number | null | undefined) => v ?? null, + tdd: (_type: string, v: number | null | undefined) => v ?? null, + unitDisplay: { unitFor: () => "kg" }, + summaries: { WEIGHT: { count: 3, latest: 80, avg7: 80, avg30: 81 } }, + weightRange: null, + weightHintRange: null, + weightTrend: { direction }, + compareBaseline: "none", + tileCompareDelta: () => null, + tileStaleDays: () => null, + directionOf: () => "up-bad", + } as unknown as DashboardTileContext; + const tiles = STRIP_TILES.filter((tile) => tile.widget === "weight"); + return tiles.flatMap((tile) => tile.cards(ctx)); } describe("dashboard weight tile direction", () => { it("finds exactly one weight tile, so an empty match cannot pass", () => { - expect(weightTileDirections()).toHaveLength(1); + expect(weightCards("down-good")).toHaveLength(1); }); it("reads the resolved target direction rather than a literal", () => { - expect(weightTileDirections()).toEqual(["{weightTrend.direction}"]); + for (const direction of ["down-good", "up-good", "neutral"]) { + const [card] = weightCards(direction); + const props = (card.node as ReactElement<{ directionSentiment: string }>) + .props; + expect(props.directionSentiment).toBe(direction); + } }); }); diff --git a/src/app/__tests__/dashboard-analytics-split.test.ts b/src/app/__tests__/dashboard-analytics-split.test.ts index d093f6f2c..f895ff77f 100644 --- a/src/app/__tests__/dashboard-analytics-split.test.ts +++ b/src/app/__tests__/dashboard-analytics-split.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; /** * v1.4.39.2 — dashboard analytics slim/thick split. @@ -26,16 +25,10 @@ import { join } from "node:path"; * slim/thick split still drives the page, so the protective intent * holds; the matchers only relaxed to tolerate the `enabled` arg. */ -const ROOT = join(__dirname, "../../.."); -const PAGE_PATH = join(ROOT, "src/app/page-client.tsx"); - -function load(path: string): string { - return readFileSync(path, "utf8"); -} describe("v1.4.39.2 — dashboard analytics slim/thick split", () => { it("mounts both the slim slice and the thick slice in parallel", () => { - const src = load(PAGE_PATH); + const src = dashboardSource(); // Slim slice — `?slice=summaries` paints the per-type tile strip. expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/); // Thick slice — the no-slice `useAnalyticsQuery` feeds the @@ -45,7 +38,7 @@ describe("v1.4.39.2 — dashboard analytics slim/thick split", () => { }); it("merges slim and thick results so call-sites stay shape-stable", () => { - const src = load(PAGE_PATH); + const src = dashboardSource(); // The merge contract: slim wins on overlapping fields, thick fills // in `bpInTargetPct*` and `glucoseByContext`. The text anchors // protect against a future refactor accidentally dropping one of diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx index 9bc8ebada..c9ab1216b 100644 --- a/src/app/__tests__/dashboard-chart-reveal.test.tsx +++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx @@ -21,11 +21,17 @@ * `resolveChartRevealState` resolver, the markup through * `renderToStaticMarkup`, and the page wiring through textual pins. */ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import type { ReactElement } from "react"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; +import { + CHART_TILES, + revealChartIds, + type ChartTileContext, +} from "@/components/dashboard/tiles/chart-tiles"; + import { CHART_REVEAL_TIMEOUT_MS, DashboardChartCell, @@ -34,8 +40,6 @@ import { import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; import { I18nProvider } from "@/lib/i18n/context"; -const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx"); - describe("resolveChartRevealState", () => { it("stays hidden while the gate is not armed (no visible charts yet)", () => { expect( @@ -145,46 +149,65 @@ describe("TrendCardSkeleton", () => { }); }); -describe("dashboard chart-reveal wiring (page.tsx)", () => { - const src = readFileSync(PAGE_PATH, "utf8"); +describe("dashboard chart-reveal wiring (the chart registry)", () => { + const GATED = [ + "weight-chart", + "bmi-chart", + "bp-chart", + "pulse-chart", + "bodyFat-chart", + "mood-chart", + "sleep-chart", + "steps-chart", + "medications", + ]; - it("derives the gated id list from the chart visibility gates", () => { - expect(src).toMatch(/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/); - expect(src).toMatch(/useDashboardChartReveal\(revealChartIds\)/); + it("derives the gated id list from the visible chart cards", () => { + expect(revealChartIds(CHART_TILES)).toEqual(GATED); + // A hidden card drops out of the list the reveal waits for. + expect( + revealChartIds(CHART_TILES.filter((tile) => tile.id !== "bp-chart")), + ).toEqual(GATED.filter((id) => id !== "bp-chart")); + expect(dashboardSource()).toMatch( + /useDashboardChartReveal\(revealChartIds\(chartTiles\)\)/, + ); }); it("every data-backed chart reports through onDataReady", () => { - for (const id of [ - "weight-chart", - "bmi-chart", - "bp-chart", - "pulse-chart", - "bodyFat-chart", - "mood-chart", - "sleep-chart", - "steps-chart", - "medications", - ]) { - expect(src).toContain(`onDataReady={() => markChartReady("${id}")}`); + const markChartReady = vi.fn(); + const ctx = { + t: (key: string) => key, + unitDisplay: { + unitFor: () => "kg", + transformFor: () => ({ factor: 1, offset: 0 }), + }, + user: { heightCm: 180, timezone: "Europe/Berlin" }, + summaries: {}, + compareBaseline: "none", + preloadedSeries: undefined, + markChartReady, + } as unknown as ChartTileContext; + for (const tile of CHART_TILES.filter((t) => GATED.includes(t.id))) { + const node = tile.render(ctx) as ReactElement<{ + onDataReady?: () => void; + }>; + node.props.onDataReady?.(); + expect(markChartReady).toHaveBeenLastCalledWith(tile.id); } + expect(markChartReady).toHaveBeenCalledTimes(GATED.length); }); it("gated entries render inside DashboardChartCell", () => { - expect(src).toMatch( + expect(dashboardSource()).toMatch( /entry\.revealGated\s*\?[\s\S]*?/, ); }); it("self-skeletoning cards (achievements / workouts) stay outside the gate", () => { - // Their entries never set `revealGated`, so the ungated branch - // renders them directly. - const achievements = src.match( - /charts\.push\(\{\s*id:\s*"achievements"[\s\S]*?\}\);/, - ); - const workouts = src.match( - /charts\.push\(\{\s*id:\s*"recentWorkouts"[\s\S]*?\}\);/, - ); - expect(achievements?.[0]).not.toContain("revealGated"); - expect(workouts?.[0]).not.toContain("revealGated"); + for (const id of ["achievements", "recentWorkouts", "vorsorge"]) { + const tile = CHART_TILES.find((t) => t.id === id); + expect(tile, id).toBeDefined(); + expect(tile?.revealGated, id).toBeUndefined(); + } }); }); diff --git a/src/app/__tests__/dashboard-demo-checklist.test.ts b/src/app/__tests__/dashboard-demo-checklist.test.ts index 0e7f0b50f..f96f909ab 100644 --- a/src/app/__tests__/dashboard-demo-checklist.test.ts +++ b/src/app/__tests__/dashboard-demo-checklist.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -13,7 +14,6 @@ import { join } from "node:path"; * client mounts the checklist only without it. */ const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); -const CLIENT_PATH = join(process.cwd(), "src/app/page-client.tsx"); describe("dashboard setup checklist on a demo instance", () => { it("threads DEMO_MODE into every render of the dashboard client", () => { @@ -27,7 +27,7 @@ describe("dashboard setup checklist on a demo instance", () => { }); it("mounts the checklist only outside demo mode", () => { - const src = readFileSync(CLIENT_PATH, "utf8"); + const src = dashboardSource(); const mounts = src.match(/]*\/>/g) ?? []; expect(mounts).toHaveLength(1); expect(src).toContain("{demoMode ? null : }"); diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index b6c5811cb..9c0fbfd13 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -16,14 +17,13 @@ import { join } from "node:path"; * once carried it — is gone), while the page itself derives no * welcome text. */ -const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx"); const HEADER_PATH = join( process.cwd(), "src/components/dashboard/dashboard-header.tsx", ); describe("dashboard hero — page wiring", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); it("mounts the TodayHero above the tile strip", () => { const heroIdx = src.indexOf(" { }); it("the page mounts the header without a greeting prop", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); expect(src).toContain( "", ); diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx index 6ed76d946..e9283bba1 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -1,3 +1,4 @@ +import type { ReactElement } from "react"; import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -19,7 +20,8 @@ import { import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { locales } from "@/lib/i18n/config"; import type { DataSummary } from "@/lib/analytics/trends"; -import { readSource } from "@/__tests__/helpers/source-files"; +import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles"; +import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context"; /** * The dashboard HRV tile must accept either HRV type. @@ -245,20 +247,37 @@ describe("the tile the user actually sees", () => { } }); - it("does not read freshness off a hardcoded SDNN type", () => { - // The one source-level check kept, and deliberately a NEGATIVE one: it - // names the exact expression the bug lived on rather than asserting the - // current formatting of the replacement, so reformatting cannot break - // it. Freshness is per-type and captioning a live RMSSD tile from the - // SDNN lookup is the failure it guards. - const page = readSource("src/app/page-client.tsx", [ - "const tileStaleDays", - "HEART_RATE_VARIABILITY", - ]); - expect(page).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")'); - expect(page).not.toContain( - "const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY", - ); + it("reads freshness off the series the tile shows", () => { + // Freshness is per type, and captioning a live RMSSD tile from the SDNN + // lookup marked it permanently stale. The registry's HRV tile is built + // with a context whose freshness differs per type, so the caption it + // asks for names the type it used. + const hrvTile = STRIP_TILES.find((tile) => tile.widget === "hrv"); + expect(hrvTile).toBeDefined(); + for (const [summaries, type, days] of [ + [{ HRV_RMSSD: summary(30, 42) }, "HRV_RMSSD", 12], + [ + { HEART_RATE_VARIABILITY: summary(9, 55) }, + "HEART_RATE_VARIABILITY", + 20, + ], + ] as const) { + const ctx = { + t: (key: string) => key, + summaries, + hrv: pickHrvSummary(summaries), + compareBaseline: "none", + directionOf: () => "neutral", + tileCompareDelta: () => null, + tileStaleDays: (t: string | null | undefined) => + t === "HRV_RMSSD" ? 12 : t === "HEART_RATE_VARIABILITY" ? 20 : null, + } as unknown as DashboardTileContext; + expect(hrvTile?.visible(ctx), type).toBe(true); + const [card] = hrvTile?.cards(ctx) ?? []; + const props = (card.node as ReactElement<{ staleDays: number | null }>) + .props; + expect(props.staleDays, type).toBe(days); + } }); }); diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 417643651..0e20a0e51 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; +import { readSource } from "@/__tests__/helpers/source-files"; /** * v1.4.40 — dashboard Suspense boundaries. @@ -22,22 +22,16 @@ import { join } from "node:path"; * a future refactor that drops the boundary lands a failing test * rather than silently regressing the streaming-composition contract. */ -const ROOT = join(__dirname, "../../.."); -const PAGE_PATH = join(ROOT, "src/app/page-client.tsx"); - -function load(path: string): string { - return readFileSync(path, "utf8"); -} describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { it("imports Suspense from React", () => { - const src = load(PAGE_PATH); + const src = dashboardSource(); expect(src).toMatch(/from\s+"react"/); expect(src).toMatch(/\bSuspense\b/); }); it("wraps the chart-row cell in a `}>` boundary", () => { - const src = load(PAGE_PATH); + const src = dashboardSource(); // `}>{entry.node}` // exact contract: the chart row's per-cell fallback uses the same // ChartSkeleton primitive the dynamic-loaded charts already paint @@ -48,7 +42,7 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { }); it("wraps each tile-strip cell in a `` boundary with a layout-stable placeholder", () => { - const src = load(PAGE_PATH); + const src = dashboardSource(); // v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null` // fallback for an `aria-hidden` placeholder div; v1.16.0 upgraded // it to the structured `` silhouette (the @@ -64,18 +58,20 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { }); it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => { - const src = load(PAGE_PATH); - // Module-scope declaration — search outside the component body. - // Pre-fix the const was declared inside `DashboardPage` so every - // render created a fresh `{}` reference. - const moduleSliceEnd = src.indexOf("export default function DashboardPage"); + const src = readSource( + "src/components/dashboard/tiles/use-dashboard-data.ts", + "export function useDashboardData", + ); + // Module-scope declaration — search outside the hook body. Declared + // inside it, every render would create a fresh `{}` reference. + const moduleSliceEnd = src.indexOf("export function useDashboardData"); expect(moduleSliceEnd).toBeGreaterThan(0); const moduleSlice = src.slice(0, moduleSliceEnd); expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/); }); it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => { - const src = load(PAGE_PATH); + const src = dashboardSource(); // The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin // moved off the page with the greeting itself: the greeting now // lives in the header (`dashboard-header.tsx`), so the page no diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index d1404b7b9..5e3caab19 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -1,12 +1,11 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { resolveChartRowPlaceholderCount, resolveConfiguredTileCount, resolveDashboardFirstPaintGate, -} from "../page-client"; +} from "@/components/dashboard/dashboard-gates"; +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; import { DEFAULT_DASHBOARD_LAYOUT, type DashboardLayout, @@ -33,7 +32,6 @@ import { * snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the * behaviour; textual pins keep the wiring honest. */ -const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx"); describe("resolveDashboardFirstPaintGate", () => { const base = { @@ -173,14 +171,14 @@ describe("resolveChartRowPlaceholderCount (v1.16.8)", () => { describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { it("derives `configuredTileCount` through the tile-capable resolver", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); expect(src).toMatch( /const\s+configuredTileCount\s*=\s*resolveConfiguredTileCount\(layout\);/, ); }); it("reserves the chart row while the primary query loads", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); // The placeholder count keys off the layout config minus the // already-mounted (layout-gated) cells, and only while loading. // `mounted &&` pins the hydration render to the SSR output — the @@ -189,7 +187,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { expect(src).toMatch( /const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*mounted\s*&&\s*Boolean\(user\?\.heightCm\)/, ); - expect(src).toMatch(/-\s*charts\.length/); + expect(src).toMatch(/-\s*chartEntries\.length/); // The reserved cells render as aria-hidden ChartSkeletons in their // own slot. const skeletonBlock = src.match( @@ -201,7 +199,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { }); it("derives `primaryLoading` from the snapshot vs slim query", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); // v1.16.4 — `!mounted` pins SSR + the hydration render to the // skeleton branch so a late-hydrating boundary can't disagree with // the server HTML (React #418); see `useMounted`. @@ -211,21 +209,21 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { }); it("routes the gate through `resolveDashboardFirstPaintGate`", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); expect(src).toMatch( /resolveDashboardFirstPaintGate\(\{[\s\S]*?primaryLoading,[\s\S]*?\}\)/, ); }); it("renders the skeleton with the same auto-fit grid track the real strip uses", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); expect(src).toMatch( /showTileStripSkeleton\s*&&\s*\(\s* { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); expect(src).toMatch( /Array\.from\(\{\s*length:\s*configuredTileCount\s*\}\)/, ); @@ -237,7 +235,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { // Reduced motion moved into the component (the `` primitive // carries `motion-reduce:animate-none`); the per-component test in // `dashboard-chart-reveal.test.tsx` pins that. - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); const skeletonBlock = src.match( /showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/, ); @@ -248,12 +246,12 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { }); it("uses the structured silhouette as the per-tile Suspense fallback", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); expect(src).toMatch(/\}>/); }); it("gates the EmptyState on the resolved `showEmptyState` flag", () => { - const src = readFileSync(PAGE_PATH, "utf8"); + const src = dashboardSource(); expect(src).toMatch(/if\s*\(showEmptyState\)\s*\{/); }); }); diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index 81519269e..050614f17 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -1,7 +1,12 @@ +import type { ReactElement } from "react"; import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; +import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles"; +import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context"; + /** * v1.4.19 — guards for the polish pass on `/insights` and the * dashboard: @@ -28,7 +33,6 @@ import { join } from "node:path"; */ const ROOT = join(__dirname, "../../.."); -const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx"); const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx"); const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx"); @@ -38,7 +42,7 @@ function load(path: string): string { describe("v1.4.19 A3 — dashboard polish", () => { it("dashboard page does NOT render ", () => { - const src = load(DASHBOARD_PATH); + const src = dashboardSource(); // The component import + JSX use are both gone. The toggle still // lives on /insights via a separate import there. expect(src).not.toMatch(/ { // baseline plumbing along with the toggle, every chart on the // dashboard would lose its comparison overlay silently. The // resolved layout still carries the value to each chart. - const src = load(DASHBOARD_PATH); - expect(src).toContain("comparisonBaseline"); - expect(src).toContain("compareBaseline"); + const src = dashboardSource(); + expect(src).toContain("layout.comparisonBaseline"); + expect(src).toContain("compareBaseline={ctx.compareBaseline}"); }); }); @@ -203,13 +207,34 @@ describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta us * window (priorMonth / priorYear) instead of all-time. */ it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => { - const src = load(DASHBOARD_PATH); - // The fix introduces `bpComparePrior` keyed by `compareBaseline`. - expect(src).toContain("bpInTargetPctPriorMonth"); - expect(src).toContain("bpInTargetPctPriorYear"); - // The all-time shortcut from v1.4.22 A2 must not subtract `bpAll` - // for the comparison delta any more. - expect(src).not.toMatch(/bp30\s*-\s*bpAll/); + // The tile is built from the registry with a 30-day share of 80 % and + // prior periods of 70 % (month), 50 % (year) and 40 % (all time): the + // delta follows the chosen baseline and never the all-time share. + const tile = STRIP_TILES.find((t) => t.widget === "bpInTarget"); + const deltaFor = (compareBaseline: string) => { + const ctx = { + t: (key: string) => key, + compareBaseline, + directionOf: () => "neutral", + data: { + bpInTargetPct: 75, + bpInTargetPct7d: 78, + bpInTargetPct30d: 80, + bpInTargetPctAllTime: 40, + bpInTargetPctPriorMonth: 70, + bpInTargetPctPriorYear: 50, + bpInTargetCount90: 200, + bpInTargetSpanDays90: 90, + }, + } as unknown as DashboardTileContext; + expect(tile?.visible(ctx)).toBe(true); + const [card] = tile?.cards(ctx) ?? []; + return (card.node as ReactElement<{ compareDelta: number | null }>).props + .compareDelta; + }; + expect(deltaFor("lastMonth")).toBe(10); + expect(deltaFor("lastYear")).toBe(30); + expect(deltaFor("none")).toBeNull(); }); it("ships priorMonth + priorYear pcts in the analytics envelope", () => { diff --git a/src/app/__tests__/root-loading-silhouette.test.tsx b/src/app/__tests__/root-loading-silhouette.test.tsx index 146f7ca3b..d42a9ea4c 100644 --- a/src/app/__tests__/root-loading-silhouette.test.tsx +++ b/src/app/__tests__/root-loading-silhouette.test.tsx @@ -1,3 +1,4 @@ +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { readFileSync } from "node:fs"; @@ -86,10 +87,7 @@ describe("RootLoading — single skeleton source (wiring pins)", () => { join(process.cwd(), "src/app/loading.tsx"), "utf8", ); - const pageSrc = readFileSync( - join(process.cwd(), "src/app/page-client.tsx"), - "utf8", - ); + const pageSrc = dashboardSource(); it("imports the same silhouette components the page's loading phase uses", () => { expect(loadingSrc).toContain( diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 5e7e0bb45..92c43a62e 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -1,208 +1,51 @@ "use client"; -import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import React, { Suspense, useCallback, useMemo, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth"; -import { useUnitDisplayOnceMounted } from "@/hooks/use-unit-display"; -import { useMounted } from "@/hooks/use-mounted"; -import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; -import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; -import { - Activity, - Bone, - Droplet, - Droplets, - Dumbbell, - Footprints, - Gauge, - GlassWater, - Heart, - HeartPulse, - Moon, - Percent, - Plus, - Smile, - Target, - Thermometer, - TrendingUp, - Wind, -} from "lucide-react"; -import { - convertGlucose, - glucoseFractionDigits, - resolveGlucoseUnit, -} from "@/lib/glucose"; -import { cn } from "@/lib/utils"; -import { - resolveDashboardLayout, - type DashboardLayout, -} from "@/lib/dashboard-layout"; -import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; -import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { EmptyState } from "@/components/ui/empty-state"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; -import { ChartSkeleton } from "@/components/charts/chart-skeleton"; -import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { importWithRetry } from "@/lib/retry-import"; -import { - DashboardChartCell, - useDashboardChartReveal, -} from "@/components/dashboard/chart-reveal"; +import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useDashboardChartReveal } from "@/components/dashboard/chart-reveal"; import { DashboardHeader } from "@/components/dashboard/dashboard-header"; +import { EnvironmentChip } from "@/components/dashboard/environment-chip"; import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner"; -import { TodayHero } from "@/components/daily/today-hero"; -import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton"; import { QuickEntrySheets, type QuickEntryDialog, } from "@/components/dashboard/quick-entry-sheets"; import { - getRangeColorClass, - getRangeHint, - toHoursSummary, -} from "@/components/dashboard/range-display"; -import { TrendCard } from "@/components/charts/trend-card"; -import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; -import { TrendHint } from "@/components/charts/trend-hint"; -import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; + buildChartEntries, + revealChartIds, + visibleChartTiles, +} from "@/components/dashboard/tiles/chart-tiles"; +import { DashboardTiles } from "@/components/dashboard/tiles/dashboard-tiles"; +import { buildStripEntries } from "@/components/dashboard/tiles/strip-tiles"; +import { useDashboardTileContext } from "@/components/dashboard/tiles/tile-context"; +import { useDashboardData } from "@/components/dashboard/tiles/use-dashboard-data"; +import { TodayHero } from "@/components/daily/today-hero"; +import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; -import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; -import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; -import { EnvironmentChip } from "@/components/dashboard/environment-chip"; -import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker"; -import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; - -// v1.4.40 — module-scope so the option object is stable across -// renders (audit-M2). Pre-fix the same literal was declared inside the -// component body, so every render created a fresh `{}` reference; -// TanStack does a shallow compare and shrugs at the equivalent values, -// but the slot is one future-callback-field away from cache poisoning. -const DASHBOARD_QUERY_OPTS = { - staleTime: 60_000, - // v1.28.28 — refetch on window focus: the dashboard is exactly the surface - // a user leaves open in a background tab while editing its tile selection - // elsewhere; without a focus refetch the stale layout sat visible for up to - // the poll interval ("saved but nothing changed"). staleTime still bounds - // the cost to at most one refetch per minute, and the server side is SWR- - // cached, so a focus flick is a cheap cache hit. - refetchOnWindowFocus: true, -} as const; - -// v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches. -// Threaded to every chart as `preloadedCoverageDays` so a chart reads the -// batched slice ONLY for a range tab whose window fits within it (7 / 30); -// the 90 / All tabs exceed it and self-fetch the wider window. -// -// v1.30.9 — `BATCH_COVERAGE_DAYS` + the type-set + window derivations moved to -// the client-safe `@/lib/dashboard/batch-chart-types` module so the RSC -// prefetch (page.tsx) and this client read the SAME code — the batched-series -// cache key is byte-identical on both sides or the prefetch silently no-ops. - -// v1.16.8 — the loaders retry a rejected chunk import once (a lazy -// import caches its rejection permanently, so a transient 404 from a -// stale shell used to brick the card for the session) and each mount -// wraps in `` so a chunk that still fails degrades -// to ONE error card instead of bubbling to the route-level `error.tsx`. -const MoodChartLazy = dynamic( - () => - importWithRetry(() => import("@/components/charts/chart-runtime")).then( - (mod) => ({ default: mod.MoodChart }), - ), - { ssr: false, loading: () => }, -); -const MoodChart = (props: React.ComponentProps) => ( - - - -); -const MedicationComplianceChartLazy = dynamic( - () => - importWithRetry(() => import("@/components/charts/chart-runtime")).then( - (mod) => ({ default: mod.MedicationComplianceChart }), - ), - { ssr: false, loading: () => }, -); -const MedicationComplianceChart = ( - props: React.ComponentProps, -) => ( - - - -); - -// v1.30.9 — warm the shared `chart-runtime` chunk during hydration. The -// dashboard charts mount through `ssr:false` dynamics, which begin their -// import only at mount (post-hydration); firing the import at module -// evaluation lets the chunk download overlap hydration instead of queueing -// behind the first chart mount. No render-path change — the dynamics still -// resolve through the same `importWithRetry` promise (a warm module cache), -// and the `` fallback is untouched. -if (typeof window !== "undefined") { - void importWithRetry(() => import("@/components/charts/chart-runtime")); -} -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; -import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; -import { useDailyDigest } from "@/lib/queries/use-daily-digest"; -import { useSurfaceVisible } from "@/hooks/use-surface-visible"; -import { hideModuleWidgets } from "@/lib/dashboard/widget-modules"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; -import { - BATCH_COVERAGE_DAYS, - deriveBatchChartTypes, - computeLocalBatchWindow, - type BatchWindow, -} from "@/lib/dashboard/batch-chart-types"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; -import { isWindowSufficient } from "@/lib/analytics/window-confidence"; -import { buildDashboardBands, viewerBandProfile } from "@/lib/dashboard/bands"; -import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; -import { - resolveWeightTrend, - weightTrendReferenceKg, -} from "@/lib/targets/weight-trend"; -import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; -import { toProfileSex } from "@/lib/profile/sex"; -import { apiGet } from "@/lib/api/api-fetch"; - -// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to -// `@/components/dashboard/dashboard-gates`. Re-exported here so the -// existing `../page` unit-test imports stay valid while `page.tsx` thins -// to an orchestrator. -export { - resolveDashboardFirstPaintGate, - resolveConfiguredTileCount, - resolveChartRowPlaceholderCount, -} from "@/components/dashboard/dashboard-gates"; -import { dashboardTileHref } from "@/components/dashboard/tile-destinations"; -import { - resolveDashboardFirstPaintGate, - resolveConfiguredTileCount, - resolveChartRowPlaceholderCount, - pickHrvSummary, - resolveGlucoseTiles, -} from "@/components/dashboard/dashboard-gates"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import type { BatchWindow } from "@/lib/dashboard/batch-chart-types"; +import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +/** + * The dashboard: the header, the Today hero, and the tiles and charts the + * layout switches on. Which tiles exist, what each reads, when each shows and + * where it sits is the registry under `components/dashboard/tiles/`; this + * component only composes. + */ export default function DashboardPageClient({ - batchWindow: batchWindowProp, + batchWindow, demoMode = false, }: { /** - * v1.30.9 — the batched-series ISO window computed server-side from the - * PROFILE timezone and threaded down as an RSC prop. Present only when the - * RSC prefetch ran (snapshot mode, `DASHBOARD_SSR_PREFETCH !== "false"`); - * absent on the legacy / prefetch-off / fail-soft paths, where the client - * falls back to its browser-local window. Adopting the prop verbatim keeps - * the `chartSeriesBatch` key byte-identical to the key the RSC dehydrated - * under, so the prefetched slice lands instead of triggering a refetch. + * The batched-series ISO window computed server-side from the PROFILE time + * zone, present only when the RSC prefetch ran. Adopted verbatim so the + * `chartSeriesBatch` key matches the key the RSC dehydrated under. */ batchWindow?: BatchWindow; /** @@ -215,736 +58,56 @@ export default function DashboardPageClient({ const { isAuthenticated } = useAuth(); const mounted = useMounted(); // The account payload is withheld until this boundary has mounted, so the - // hydration render paints exactly what the server streamed. The dashboard - // hydrates after the app shell, whose mount already fetched `/api/auth/me`, - // and every account-derived branch below — the BMI chart's `heightCm` gate, - // the personalised weight bands, the module gates, the unit labels — would - // otherwise render one thing on the server and another here. With the RSC - // prefetch on, that costs the whole streamed dashboard: React #418, tree - // discarded, the prefetch paid for and thrown away. See + // hydration render paints exactly what the server streamed. Every + // account-derived branch (the BMI chart's height gate, the personalised + // bands, the module gates, the unit labels) would otherwise render one + // thing on the server and another here, and with the RSC prefetch on that + // costs the whole streamed dashboard (React #418). See // `useAccountOnceMounted`. const user = useAccountOnceMounted(); - const { t } = useTranslations(); - const fmt = useFormatters(); const queryClient = useQueryClient(); - // v1.32.26 — metric/imperial display choke point for the dashboard tiles + - // weight chart. `td` converts an ABSOLUTE reading (affine, so a temperature - // picks up the °F offset); `tdd` converts a DELTA / 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 { canWriteDomain } = useRecordCapabilities(); const canAddMeasurement = canWriteDomain("measurements"); const [quickEntryDialog, setQuickEntryDialog] = useState(null); - // v1.30.1 — pull-to-refresh parity for the dashboard, named as one - // of the sharpest PWA-resume-staleness surfaces in the audit (it - // already opts back into `refetchOnWindowFocus`, but a PWA resumed - // from the home screen/app-switcher, not a browser tab focus event, - // is the more common mobile path and that never fires a focus event). - // The dashboard fans out into a dozen-plus independent per-tile/chart - // queries, so — like `/insights` — refetching every currently-active - // query is the only tractable "refresh everything visible" shape. + // Pull to refresh: a PWA resumed from the home screen never fires a focus + // event, and the dashboard fans out into a dozen independent reads, so + // refetching every active query is the one tractable "refresh everything + // visible". const refreshDashboard = useCallback( () => queryClient.refetchQueries({ type: "active" }), [queryClient], ); const pull = usePullToRefresh({ onRefresh: refreshDashboard }); - // v1.4.29 — the three inline dashboard queries default to a - // 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch - // storm. None of these are real-time data; minute-scale staleness - // is fine and the dashboard's chart queries already match this - // cadence. - // - // v1.4.40 — `DASHBOARD_QUERY_OPTS` hoisted to module scope - // (audit-M2 — stable reference across renders). - // v1.4.39.2 — split the dashboard's analytics consumption so the - // tile-strip paints from the slim slice and the BD-Zielbereich + - // glucose tiles stream in from the thick slice afterwards. - // - // Pre-fix: a single `useAnalyticsQuery()` against `/api/analytics` - // (thick slice) blocked every per-type tile until the heavy fan-out - // resolved. Mood and medication tiles paint from their own dedicated - // endpoints and arrived first; the per-type measurement tiles then - // arrived as one burst once the full slice landed — the maintainer reported - // this "blocked-then-burst" pattern as "etwas nervig". - // - // Post-fix: two parallel queries. The slim slice (`?slice=summaries`) - // resolves the per-type DataSummary headlines + `lastSeenByType` and - // typically returns in well under a second once the rollup tier is - // warm; that paints every per-type tile in the strip. The thick - // slice resolves `bpInTargetPct*` + `glucoseByContext` for the - // BD-Zielbereich and glucose tiles, which stream in independently. - // Both queries share `caches.analytics` server-side so warm hits - // stay free, and TanStack's parallel-mounting keeps the network fan- - // out flat. - // Unified first-paint snapshot (reversible rollout flag, default ON). - // Every tile hydrates from ONE un-gated `/api/dashboard/snapshot` cell - // so the whole strip shares one completion moment and the - // `/api/auth/me` round-trip leaves the cold critical path. Set - // `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy - // four independent cells. - const snapshotEnabled = isDashboardSnapshotEnabled(); - const snapshotQuery = useDashboardSnapshot(snapshotEnabled); - - // The Today hero reads the unified daily digest (the S1 DTO) from - // its own cached route. It is the day's data, so no module owns it: the - // `insights` key means AI analysis, and the digest leaves its model-written - // lead out on its own when that is off. Gated on auth only. - const digestQuery = useDailyDigest(isAuthenticated); - - // v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web - // hero (live use: uneven, wasted tile space). The snapshot still - // resolves `scoreRings` server-side for the iOS selection contract; the web - // hero just no longer renders them, so no client-side ring ordering here. - - const analyticsSlimQuery = useAnalyticsQuery({ - slice: "summaries", - enabled: !snapshotEnabled && isAuthenticated, + const dashboard = useDashboardData({ + isAuthenticated, + mounted, + user, + batchWindowProp: batchWindow, }); - const analyticsThickQuery = useAnalyticsQuery({ - enabled: !snapshotEnabled && isAuthenticated, - }); - const data = useMemo(() => { - // v1.7.0 — snapshot path: assemble the same `AnalyticsData` - // shape from the single snapshot cell so every downstream tile - // reads unchanged. `extras` is null on a rollup-coverage miss - // (two-phase contract) → the BD-Zielbereich + glucose fields stay - // undefined and those tiles render their per-tile shimmer while the - // rest of the strip paints. - if (snapshotEnabled) { - const snap = snapshotQuery.data; - if (!snap) return undefined; - return { - summaries: snap.tiles.summaries, - lastSeenByType: snap.tiles.lastSeenByType, - // v1.28.x — latest-night source-discrepancy annotation for the - // sleep tile's discreet "sources disagree" marker. Optional on - // the snapshot (additive contract), so `?? null` here. - 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, - }; - } - // v1.4.39.3 — the merge moved to `mergeSlimAndThickAnalytics` so - // the empty-slim-vs-populated-thick edge has direct unit - // coverage. Pre-fix the inline `slim?.summaries ?? thick?.summaries` - // short-circuited on a truthy-but-empty `{}` from the slim slice - // and blanked the tile strip even when thick carried the full - // payload — the regression the maintainer's v1.4.39.3 e2e CI flagged across - // eight dashboard / chart specs. The helper falls back to thick - // when slim resolves with no content and otherwise keeps the - // v1.4.39.2 slim-wins-first progressive-paint contract. - const merged = mergeSlimAndThickAnalytics( - analyticsSlimQuery.data, - analyticsThickQuery.data, - ); - 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, - }; - }, [ - snapshotEnabled, - snapshotQuery.data, - analyticsSlimQuery.data, - analyticsThickQuery.data, - ]); + const { digestQuery, layout, primaryLoading } = dashboard; + const tileContext = useDashboardTileContext({ dashboard, user }); - // 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; the fallback would otherwise - // keep shading the default band for someone who has set a target. - 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<{ - entries: Array<{ date: string; score: number; samples: number }>; - summary: DataSummary; - }>("/api/mood/analytics"); - }, - // The route refuses with the mood module off; do not ask. - enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible, - ...DASHBOARD_QUERY_OPTS, - }); - const moodData = snapshotEnabled - ? snapshotQuery.data - ? { - entries: snapshotQuery.data.tiles.mood.entries, - summary: (snapshotQuery.data.tiles.mood.summary ?? - undefined) as DataSummary, - } - : undefined - : moodDataLegacy; - - // v1.4.27 — the dashboard's `` retired (it - // duplicated the much-richer `/insights` advisor surface). The advisor - // query lives on `/insights` directly; the dashboard no longer needs - // its own hook subscription, so the local `useInsightsAdvisorQuery` - // call dropped with the preview. - - // v1.4.27 — per-tile availability gates live a few lines below as the - // existing `hasWeight` / `hasBp` / `hasPulse` / `hasBodyFat` / `hasMood` / - // `hasSleep` / `hasSteps` flags. They mirror `hasMetricData` from - // `src/lib/insights/metric-availability.ts` for the routed Insights - // surfaces — both branches read `summaries[METRIC].count > 0`. - - const w = data?.summaries?.WEIGHT; - const sys = data?.summaries?.BLOOD_PRESSURE_SYS; - const dia = data?.summaries?.BLOOD_PRESSURE_DIA; - const p = data?.summaries?.PULSE; - // v1.15.12 — the resting-pulse target band is judged against the - // RESTING_HEART_RATE series (Apple's clean daily resting figure), not - // raw PULSE which mixes in workout HR. When the user has resting rows, - // the pulse tile shows the resting figure + the resting-band colour; - // when only raw PULSE exists, the tile shows heart rate WITHOUT the - // resting-band colour overlay (no "outside target" over workout HR). - const rhr = data?.summaries?.RESTING_HEART_RATE; - const hasRestingHr = (rhr?.count ?? 0) > 0; - const pulseTileSummary = hasRestingHr ? rhr : p; - // The pulse widget shows ONE of two statistics, and its name says which: - // with resting rows it is the resting heart rate (one figure a day, the - // same series the day view names "Resting heart rate"); without them it - // is the pulse, whose latest value is the latest day's pulse (the mean of - // its hours' means, `summaries-slice.ts`), the number the day view shows - // as "Pulse" for that day. Calling the resting figure "Pulse" put 62 on - // the dashboard beside a day view reading 77 for the same name. - const pulseLabel = hasRestingHr - ? t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE) - : t("dashboard.pulseShort"); - const pulseTitle = hasRestingHr - ? t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE) - : t("dashboard.pulse"); - const bf = data?.summaries?.BODY_FAT; - const sleepSummary = data?.summaries?.SLEEP_DURATION; - // v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep - // totals in MINUTES (the server collapses the per-stage rows into one - // night value; see `summaries-slice.ts`). The sleep tile renders hours, - // so convert every value field minutes→hours here while keeping the - // staleness / count metadata untouched. This is the web-parity twin of - // the iOS dashboard-summary route which already emits `unit:"h"`. - const sleepSummaryHours = sleepSummary - ? toHoursSummary(sleepSummary) - : undefined; - const stepsSummary = data?.summaries?.ACTIVITY_STEPS; - // v1.4.25 — VO2 max secondary-metric tile. /api/analytics - // auto-populates this summary because the route iterates over the - // full measurementTypeEnum.options list; no backend change needed. - const vo2Summary = data?.summaries?.VO2_MAX; - // v1.28.52 — additional vitals + body-composition summaries. Each rides - // the same summaries slice (`computeSummariesSlice` iterates the full - // measurement-type enum), so no backend change was needed; the strip - // tiles below self-gate on `count > 0`. - // HRV is stored under two types and the tile has to accept either: SDNN - // (`HEART_RATE_VARIABILITY`, Apple Health / Health Connect) or nightly - // RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP / Fitbit / Google Health and - // bridge pushes). Reading - // SDNN alone left a ring / strap account with a Settings toggle that - // rendered nothing while `/insights/hrv` charted the same data. The - // helper keeps the union in one place and hands back which type won, so - // the freshness caption and the label below follow the series shown. - const { - summary: hrvSummary, - type: hrvType, - labelKey: hrvLabelKey, - } = pickHrvSummary(data?.summaries); - const spo2Summary = data?.summaries?.OXYGEN_SATURATION; - const respRateSummary = data?.summaries?.RESPIRATORY_RATE; - const wristTempSummary = data?.summaries?.WRIST_TEMPERATURE; - const muscleMassSummary = data?.summaries?.MUSCLE_MASS; - const bodyWaterSummary = data?.summaries?.TOTAL_BODY_WATER; - const boneMassSummary = data?.summaries?.BONE_MASS; - // v1.29 — fluid intake strip tile. Synthetic summary key, not a real - // MeasurementType — derived server-side from `NutrientIntakeDay` - // (nutrient="water", summed across sources); see `dashboard/snapshot.ts`. - const waterIntakeSummary = data?.summaries?.NUTRIENT_WATER; - const moodSummary = moodData?.summary; - - // Resolve full dashboard layout — controls visibility + order of every widget - const layout = resolveDashboardLayout(layoutData); const renderFilteredHeroAllClear = (layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS).length < PRIORITY_ITEM_KINDS.length; - /** - * v1.4.16 — comparison baseline (Vormonat / Vorjahr) read - * from the resolved layout so every chart + tile on the dashboard - * receives the same value. "none" = comparison off (pre-B8 default). - */ - const compareBaseline = layout.comparisonBaseline ?? "none"; - /** - * v1.4.16 — derive the tile-delta value (current 30d avg - * minus prior-period 30d avg) for any DataSummary. Returns null - * when comparison is off OR either side is missing data so the - * tile can suppress the callout cleanly. - */ - 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; - }; - - /** - * v1.4.34 — read the per-type freshness map and surface the - * `daysAgo` value when the metric is older than a week. The - * tile-strip below forwards the result to `` - * which picks the bucket-aware copy (Xd / X weeks / X months) and - * paints the caption on the tile. Returns `null` for metrics with no - * reading yet OR within the fresh window so call sites stay - * undefined-safe and tiles with recent data paint byte-identical - * with the pre-v1.4.34 contract. - */ - 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; - /** - * v1.4.15 — whether the widget's *tile* in the strip shows. - * Independent of the chart visibility so the user can hide chart but - * keep the tile (or vice versa) from Settings → Dashboard. Falls back - * to chart visibility for layouts saved before v1.4.15. - */ - 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; - - // Data-floor gates (widget shows iff visible AND has data) - const hasWeight = (w?.count ?? 0) > 0; - const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0; - // v1.15.12 — the pulse tile shows resting HR when available, else - // raw PULSE; either signal having data shows the tile. - const hasPulse = (p?.count ?? 0) > 0 || (rhr?.count ?? 0) > 0; - const hasBodyFat = (bf?.count ?? 0) > 0; - const hasMood = (moodSummary?.count ?? 0) > 0; - const hasSleep = (sleepSummary?.count ?? 0) > 0; - const hasSteps = (stepsSummary?.count ?? 0) > 0; - const hasVo2 = (vo2Summary?.count ?? 0) > 0; - // v1.28.52 — data floors for the new vitals + body-composition tiles. - const hasHrv = (hrvSummary?.count ?? 0) > 0; - const hasSpo2 = (spo2Summary?.count ?? 0) > 0; - const hasRespRate = (respRateSummary?.count ?? 0) > 0; - const hasWristTemp = (wristTempSummary?.count ?? 0) > 0; - const hasMuscleMass = (muscleMassSummary?.count ?? 0) > 0; - const hasBodyWater = (bodyWaterSummary?.count ?? 0) > 0; - const hasBoneMass = (boneMassSummary?.count ?? 0) > 0; - // v1.29 — data floor for the fluid-intake strip tile. - const hasWaterIntake = (waterIntakeSummary?.count ?? 0) > 0; - /** - * v1.4.33 — gate the BD-Zielbereich tile so a literal "0,0 %" - * placeholder doesn't ride on the dashboard when none of the user's - * paired readings sit inside the target band. The historical - * behaviour (`!= null`) painted the tile whenever the analytics - * route emitted a numeric percentage, including the legitimate-but- - * misleading zero. The corrected gate requires at least one window - * (7d, 30d, all-time) to report a non-zero share. When every - * window is zero, the tile is hidden — the user sees the BP charts - * + the Insights blood-pressure target panel for the deeper analysis - * instead. - * The audit's F4 reproduction sat on 540 BP samples with all sub- - * windows reading 0 % because the seed data straddled the target - * ceiling; the tile keeps its place once even one sub-window - * crosses zero. - */ - const hasBpInTarget = - data?.bpInTargetPct != null && - [ - data?.bpInTargetPct, - data?.bpInTargetPct7d, - data?.bpInTargetPct30d, - data?.bpInTargetPctAllTime, - ].some((pct) => pct != null && pct > 0); - - // Tile (strip) gates — controlled by the new `tileVisible` flag. - const showWeightTile = isTileVisible("weight") && hasWeight; - const showBpTiles = isTileVisible("bp") && hasBp; - const showPulseTile = isTileVisible("pulse") && hasPulse; - const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat; - const showMoodTile = isTileVisible("mood") && hasMood; - const showSleepTile = isTileVisible("sleep") && hasSleep; - // v1.18.0 — steps are activity data tied to the workouts module - // surface; AND the resolved module flag (fail-open: only an explicit - // `false` hides it) so a disabled-workouts account drops the tile. - const showStepsTile = - isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false; - const showVo2Tile = isTileVisible("vo2Max") && hasVo2; - // v1.28.52 — strip-tile gates for the new vitals + body-composition - // metrics. Each follows the VO2max precedent: layout toggle AND a - // non-empty summary, so an account without that metric sees no tile. - const showHrvTile = isTileVisible("hrv") && hasHrv; - const showSpo2Tile = isTileVisible("oxygenSaturation") && hasSpo2; - const showRespRateTile = isTileVisible("respiratoryRate") && hasRespRate; - const showWristTempTile = isTileVisible("wristTemperature") && hasWristTemp; - const showMuscleMassTile = isTileVisible("muscleMass") && hasMuscleMass; - const showBodyWaterTile = isTileVisible("totalBodyWater") && hasBodyWater; - const showBoneMassTile = isTileVisible("boneMass") && hasBoneMass; - // v1.29 — fluid intake strip tile gate: layout toggle AND a non-empty - // summary, same precedent as the vitals/body-composition tiles above. - const showWaterIntakeTile = isTileVisible("waterIntake") && hasWaterIntake; - const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; - - // Chart (lower row) gates — controlled by the legacy `visible` flag. - const showWeightChart = isChartVisible("weight") && hasWeight; - const showBpCharts = isChartVisible("bp") && hasBp; - const showPulseChart = isChartVisible("pulse") && hasPulse; - const showBodyFatChart = isChartVisible("bodyFat") && hasBodyFat; - const showMoodChart = isChartVisible("mood") && hasMood; - const showSleepChart = isChartVisible("sleep") && hasSleep; - const showStepsChart = isChartVisible("steps") && hasSteps; - const showMedicationsCard = isChartVisible("medications"); - // v1.18.2 — Vorsorge summary card on the chart row (opt-in). Always-on - // data surface, so it gates on the layout toggle alone. - const showVorsorgeCard = isChartVisible("vorsorge"); - // `layoutData` is undefined until the real layout (snapshot or legacy - // widgets) resolves; `resolveDashboardLayout(undefined)` falls back to - // DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default. - // Gating layout-toggle-only cards on that fallback flashed them in for - // the load window and then retracted them for any user who had turned - // the widget OFF (their real layout arriving after first paint). The - // data-driven tiles tolerate the fallback because they also gate on a - // data floor; the achievements + recent-workouts cards have no floor, - // so wait for the real layout before committing them. - const layoutResolved = layoutData != null; - // v1.4.15 — recent unlocks dashboard surface. The card itself - // self-handles the loading skeleton + empty state (CTA → /achievements), - // so we only need the layout-toggle gate here. No data-floor check (the - // empty card is intentional — the maintainer wants the user to discover - // the feature). - const showAchievementsCard = layoutResolved && isChartVisible("achievements"); - // v1.4.32 — recent workouts dashboard tile. Self-gates on the - // workouts query response so we only need the layout toggle here; - // the tile renders an Apple-Health-onboarding hint when empty. Same - // layout-resolved gate as the achievements card — default-visible with - // no data floor, so it would otherwise flash in then retract for a user - // who hid it. - const showRecentWorkoutsTile = - layoutResolved && - isChartVisible("recentWorkouts") && - user?.modules?.workouts !== false; - - // v1.16.0 — shared reveal gate for the chart row. Every data-backed - // chart mounts as soon as its visibility gate flips (so the per-chart - // queries fan out in parallel), but the cells hold their layout-stable - // skeletons until EVERY gated chart reported its data settled — or the - // 2 s timeout fires so one slow widget cannot block the row (see - // `chart-reveal.tsx`). Pre-fix the cheap `/api/mood/analytics` read - // made the mood chart paint first and the measurement charts trickle - // in one after another. The id list mirrors the `charts[]` entry ids - // below; the achievements + recent-workouts cards stay outside the - // gate (they self-skeleton and carry no chart-shaped footprint). - const revealChartIds: string[] = []; - if (showWeightChart) { - revealChartIds.push("weight-chart"); - if (user?.heightCm) revealChartIds.push("bmi-chart"); - } - if (showBpCharts) revealChartIds.push("bp-chart"); - if (showPulseChart) revealChartIds.push("pulse-chart"); - if (showBodyFatChart) revealChartIds.push("bodyFat-chart"); - if (showMoodChart) revealChartIds.push("mood-chart"); - if (showSleepChart) revealChartIds.push("sleep-chart"); - if (showStepsChart) revealChartIds.push("steps-chart"); - if (showMedicationsCard) revealChartIds.push("medications"); + // Every data-backed chart mounts as soon as its gate flips (so the chart + // reads fan out in parallel), but the cells hold their skeletons until + // every gated chart reported its data settled, or the reveal timeout fired + // (see `chart-reveal.tsx`), so the row swaps in one frame. + const chartTiles = visibleChartTiles(tileContext); const { revealed: chartsRevealed, markReady: markChartReady } = - useDashboardChartReveal(revealChartIds); - - // Glucose widget — visible iff layout enables it AND at least one reading exists. - // Glucose has no separate chart slot today, so the tile flag is the - // single source of truth for it. - const glucoseWidgetVisible = isTileVisible("glucose"); - const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); - const glucoseByContext = data?.glucoseByContext ?? {}; - // #943 — eligibility comes from `resolveGlucoseTiles`, which walks the - // untagged bucket alongside the four named contexts. The gate used to be - // an inline filter over the named contexts only, so an account whose - // source never writes a meal-time tag saw nothing. - const glucoseTiles = resolveGlucoseTiles(glucoseByContext); - const showGlucoseCards = glucoseWidgetVisible && glucoseTiles.length > 0; - // v1.18.6 — band / target math is computed SERVER-side in the snapshot - // DTO (`targetBands`) so the client stops recomputing it from the - // profile (audit finding #3). When snapshot mode is on, read the - // resolved numbers straight from `snapshotQuery.data.targetBands`; the - // `!snapshotEnabled` branch keeps the legacy client-compute path so the - // non-snapshot fallback still works. The two produce byte-identical - // numbers (the server calls the SAME helpers — see - // `buildTargetBands` + its parity test). - const serverBands = snapshotEnabled - ? snapshotQuery.data?.targetBands - : undefined; - // Resolve the bands through the SAME `buildDashboardBands` helper the - // server snapshot uses, so the snapshot path and the client fallback - // are byte-identical by construction (no second inline copy of the - // band math to drift). Always compute the client copy from the profile - // — it is pure + cheap — so a still-loading snapshot frame keeps real - // bands rather than blank charts; `serverBands ?? clientBands` then - // prefers the authoritative server numbers the moment they arrive. - // Inputs only change with profile facts, so memoise the band math — it - // ran on every dashboard render (and got discarded by `serverBands ??` - // in the snapshot steady state) before this. Keyed on the `user` object - // identity so the React Compiler can preserve the memo (a narrower - // property list trips `preserve-manual-memoization`). - // v1.34 — the user's own targets ride the same memo. In snapshot mode - // `thresholdsData` is never fetched, so the client copy falls back to the - // default bands for the frames before `serverBands` lands — exactly - // as it already did for every other profile-derived number. - // v1.36.x — the profile this fallback reads is the CALLER's, and under a - // switch the caller is the delegate. `viewerBandProfile` is where that is - // decided and argued; the effect here is that a delegated visit draws no - // client-side band at all and waits for the snapshot's, which resolve - // against the record. - const inSharedRecord = user?.accountAccess?.active != null; - 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 bands = serverBands ?? clientBands; - // v1.39 — which way a weight change counts as progress, judged against the - // stored target. The snapshot carries the server's answer; the - // snapshot-disabled fallback asks the same resolver from the same profile - // the band fallback above reads, so the two paths cannot disagree. - const weightTrend = - (snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ?? - resolveWeightTrend( - resolveWeightTargetOverride(clientBandProfile.thresholdsJson), - weightTrendReferenceKg(w), - ); - // v1.43 — every other tile's direction comes resolved from the server - // (`tiles.directions`, against the record's own targets and profile). The - // snapshot-disabled path has no resolved answer and renders neutral rather - // than guessing a polarity. - const tileDirections = snapshotEnabled - ? snapshotQuery.data?.tiles.directions - : undefined; - const directionOf = (key: string): TrendDirectionSentiment => - tileDirections?.[key]?.direction ?? "neutral"; - const bpTargets = bands.bpTargets; - const weightRange = bands.weightRange; - // The range hint tooltip prints its band bounds in the display unit, so scale - // the kg thresholds by the weight factor (offset-free). The colour-classing - // below stays in canonical kg — it compares a canonical avg against the - // canonical range, so both sides remain in the same unit. - 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; - // Scale the kg chart bands into the display unit so the shaded zone tracks - // the converted line (factor-only; weight has no offset). Metric = identity. - const weightDisplayBands = - weightBands && unitDisplay.preference === "imperial" - ? weightBands.map((band) => ({ - ...band, - min: unitDisplay.toDisplay("WEIGHT", band.min), - max: unitDisplay.toDisplay("WEIGHT", band.max), - })) - : weightBands; - 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; - const bpSysRange = bands.bpSysRange; - const bpDiaRange = bands.bpDiaRange; - const pulseDisplayRange = bands.pulseDisplayRange; - const pulseBands = bands.pulseBands; - const bodyFatBands = bands.bodyFatBands; - - // v1.18.6 — ONE batched fetch for every visible non-sleep chart series - // (audit finding #2). Pre-fix, each `` fired its own - // `/api/measurements?...&source=rollup` round-trip, so a 6-8 chart - // dashboard issued 6-8 parallel requests on load. The batched endpoint - // returns all of them in one response; each chart reads its slice via - // `preloadedSeries` instead of self-fetching. Sleep stays on its - // dedicated per-night `/series` adapter (not rollup-backed), so it is - // never included here and the sleep chart keeps its own fetch. - // - // The window matches the charts' default 30-point view (the dashboard - // mounts them with no range-tab interaction); a chart whose window - // widens past this slice (range tab / comparison) drops batched - // coverage and self-fetches for the new window — see `usePreloaded`. - // - // v1.30.9 — the type-set derivation is the shared `deriveBatchChartTypes`, - // the SAME function the RSC prefetch runs over the SAME snapshot payload - // (`layout` + `data.summaries` in snapshot mode ARE the snapshot fields the - // server dehydrated). One implementation → the CSV, and therefore the - // batched-series cache key, is byte-identical on both sides by construction. - const batchChartTypes = deriveBatchChartTypes(layout, data?.summaries); - - // Stable day-bucketed window so the cache key is stable across the day - // (mirrors the chart's own ISO-window stability contract). Computed - // once per mount via lazy state so a re-render never re-keys the batch. - // - // v1.30.9 — the server-computed PROFILE-tz window wins when present (it - // rides the serialized RSC payload, so SSR and the first hydration render - // see the identical object → the `chartSeriesBatch` key matches the key the - // RSC dehydrated under, zero hydration seam). The browser-local fallback - // stands for the prefetch-off / legacy / fail-soft paths where no prop - // arrives. Lazy `useState` with a prop-derived initial value is - // deterministic across the SSR/CSR pair. - const [batchWindow] = useState( - () => batchWindowProp ?? computeLocalBatchWindow(), - ); - - const { data: batchedSeries } = useQuery({ - queryKey: queryKeys.chartSeriesBatch( - batchChartTypes.join(","), - batchWindow.from, - batchWindow.to, - ), - enabled: isAuthenticated && mounted && batchChartTypes.length > 0, - staleTime: 60_000, - gcTime: 5 * 60_000, - refetchOnWindowFocus: false, - queryFn: async () => { - const sp = new URLSearchParams(); - sp.set("types", batchChartTypes.join(",")); - sp.set("from", batchWindow.from); - sp.set("to", batchWindow.to); - const res = await apiGet<{ - series: Record< - string, - Array<{ - value: number; - measuredAt: string; - count?: number; - minValue?: number | null; - maxValue?: number | null; - }> - >; - }>(`/api/measurements/series-batch?${sp}`); - return res.series; - }, + useDashboardChartReveal(revealChartIds(chartTiles)); + const stripEntries = buildStripEntries(tileContext); + const chartEntries = buildChartEntries(chartTiles, { + ...tileContext, + preloadedSeries: dashboard.preloadedSeries, + markChartReady, }); - // Per-chart slices in the chart's `MeasurementApiRow` shape. The batched - // rows already carry `measuredAt` / `value` / `count` / min / max — the - // exact fields the chart's bucketing loop reads. - const preloadedSeries = batchedSeries; - // v1.7.0 — the primary data source differs by flag. In snapshot - // mode `analyticsSlimQuery` is `enabled: false`, and a disabled - // TanStack query reports `fetchStatus: "idle"` → `isLoading` is - // always `false`. Gating the skeleton on the slim query then - // never fires under snapshot mode, so the empty-state branch - // below flashes for the whole snapshot fetch. Key the loading - // flag off whichever query is actually driving the tiles. - // v1.16.4 — `!mounted` pins the SSR pass AND the hydration - // render to the skeleton branch. Server-side the data queries - // are `enabled: false` (no auth) → `isLoading: false`, which - // used to select the empty state; a late-hydrating boundary - // then saw `isAuthenticated` already resolved and rendered the - // skeleton instead — React #418. See `useMounted`. - // v1.16.9 — hoisted out of the strip IIFE: the hero band below - // shares the same gate so its skeleton and the tile silhouettes - // swap to content in the same render pass. - const primaryLoading = - !mounted || - (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); - return (
` with a retry action rather than silently - falling through to nothing. The dense tile grid + charts below - are untouched. */} { - // v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero - // (the LCP element) instead of a skeleton. This is hydration-safe ONLY - // because of a bound invariant: the digest is server-DEHYDRATED - // (`page.tsx` prefetch → `setQueryData(queryKeys.dailyDigest(), …)`), - // so `digestQuery.data` is present on BOTH the SSR pass AND the first - // hydration render (HydrationBoundary hydrates synchronously before - // first render; `enabled:false` only gates FETCHING, not cached-data - // reads) — both sides render identical HTML, no React #418. - // - // The one divergence class the old `!mounted` gate guarded against — - // a client-only IndexedDB-restored cache painting the hero while the - // server rendered a skeleton — CANNOT bite here: `["daily", …]` is NOT - // in the persist allowlist (`PERSIST_ALLOWLIST_HEADS` in - // `@/lib/pwa/query-persister` — `dashboard` / `chart-data` + the exact - // `["user","dashboardWidgets"]` tuple only), so the digest never - // rehydrates from disk. The error / empty branches stay reachable only - // post-mount. DO NOT add `["daily", …]` to that allowlist, or render - // the hero from any client-only source, without revisiting this gate. - // v1.42 — the top card can be switched off in the layout settings; - // only an explicit `false` hides it, and with it its skeleton and - // its error card. + // The Today hero above the tile strip: the day's read, the score and + // freshness face, and the worth-a-look rail. DATA-FIRST, so the SSR + // pass paints the populated hero (the LCP element). That is + // hydration-safe ONLY because the digest is server-dehydrated + // (`page.tsx` prefetch), so `digestQuery.data` is present on both the + // SSR pass and the first hydration render, and because `["daily", …]` + // is NOT in the persist allowlist (`PERSIST_ALLOWLIST_HEADS` in + // `@/lib/pwa/query-persister`), so it never rehydrates from disk. Do + // not add it there, or render the hero from any client-only source, + // without revisiting this gate. A failed read is an error card with + // retry, distinct from the honest empty degrade (the hero returns + // null). The card can be switched off in the layout settings; only an + // explicit `false` hides it, its skeleton and its error card. layout.todayCardVisible === false ? null : digestQuery.data ? ( ) : !mounted || digestQuery.isLoading ? ( @@ -1011,1313 +155,34 @@ export default function DashboardPageClient({ ) : null } - {/* v1.42 (#615) — a quiet note about the newest stored environment - day (high pollen, a hot night, very poor air). Renders nothing - unless one of those applies, and nothing before mount (the module - map and the overview are client reads). Not a layout widget. */} + {/* A quiet note about the newest stored environment day (high pollen, a + hot night, very poor air). Nothing unless one applies, and nothing + before mount (the module map and the overview are client reads). + Not a layout widget. */} {mounted && } - {/* v1.18.6 — the spotlight tour launcher moved to the app-shell - * (`AuthShell`) so its overlay survives the cross-page - * `router.push`es the module tour makes. The dashboard no longer - * mounts it. */} - - {/* Quick Entry Sheets — bottom-sheet on ` setQuickEntryDialog(null)} /> - {(() => { - type TrendEntry = { id: string; order: number; node: React.ReactNode }; - const trendCards: TrendEntry[] = []; + setQuickEntryDialog("measurement")} + /> - if (showWeightTile) { - trendCards.push({ - id: "weight", - order: widgetOrder("weight"), - node: ( - - ), - }); - } - if (showBpTiles) { - // BP is conceptually one signal but visually two tiles (sys + dia) - // so the user sees both numbers side by side at the same size as - // every other tile in the strip. v1.4.3 first attempted a - // combined tile with `secondary` values — the maintainer preferred two - // distinct tiles with consistent symmetric widths. - trendCards.push({ - id: "bp-sys", - order: widgetOrder("bp"), - node: ( - - ), - }); - trendCards.push({ - id: "bp-dia", - // Sub-order keeps dia immediately after sys; the +0.001 leaves - // headroom for any future BP-related tile slotted between them. - order: widgetOrder("bp") + 0.001, - node: ( - - ), - }); - } - if (showPulseTile) { - trendCards.push({ - id: "pulse", - order: widgetOrder("pulse"), - node: ( - - ), - }); - } - if (showBodyFatTile) { - trendCards.push({ - id: "bodyFat", - order: widgetOrder("bodyFat"), - node: ( - - ), - }); - } - if (showMoodTile) { - trendCards.push({ - id: "mood", - order: widgetOrder("mood"), - node: ( - - ), - }); - } - if (showSleepTile) { - trendCards.push({ - id: "sleep", - order: widgetOrder("sleep"), - node: ( - - ) : null - } - /> - ), - }); - } - if (showStepsTile) { - trendCards.push({ - id: "steps", - order: widgetOrder("steps"), - node: ( - - ), - }); - } - // v1.4.25 — VO2 max trend tile. Self-gates on the - // `vo2Max` widget being enabled (Settings → Dashboard) AND - // the analytics summary carrying at least one sample. Higher - // VO2 max is better, so the directionSentiment is up-good and - // an upward 30-day slope renders the green arrow. Unit - // matches the canonical DB unit in - // src/lib/validations/measurement.ts. - if (showVo2Tile) { - trendCards.push({ - id: "vo2Max", - order: widgetOrder("vo2Max"), - node: ( - - ), - }); - } - // v1.28.52 — vitals strip tiles (HRV / SpO2 / respiratory rate / - // wrist temperature). Each self-gates on the layout toggle AND a - // non-empty summary (see the `showXTile` gates above), links to its - // /insights detail page, and forwards the per-type freshness caption. - // Units + sentiments mirror the matching /insights sub-page: - // higher HRV and higher SpO2 are better (up-good); respiratory rate - // and wrist temperature read as neutral (stability is the signal). - if (showHrvTile) { - trendCards.push({ - id: "hrv", - order: widgetOrder("hrv"), - node: ( - - ), - }); - } - if (showSpo2Tile) { - trendCards.push({ - id: "oxygenSaturation", - order: widgetOrder("oxygenSaturation"), - node: ( - - ), - }); - } - if (showRespRateTile) { - trendCards.push({ - id: "respiratoryRate", - order: widgetOrder("respiratoryRate"), - node: ( - - ), - }); - } - if (showWristTempTile) { - trendCards.push({ - id: "wristTemperature", - order: widgetOrder("wristTemperature"), - node: ( - - ), - }); - } - // v1.28.52 — body-composition strip tiles (muscle mass / total body - // water / bone mass). Higher muscle mass reads as up-good; body water - // and bone mass are neutral (stability, not direction, is the signal). - if (showMuscleMassTile) { - trendCards.push({ - id: "muscleMass", - order: widgetOrder("muscleMass"), - node: ( - - ), - }); - } - if (showBodyWaterTile) { - trendCards.push({ - id: "totalBodyWater", - order: widgetOrder("totalBodyWater"), - node: ( - - ), - }); - } - if (showBoneMassTile) { - trendCards.push({ - id: "boneMass", - order: widgetOrder("boneMass"), - node: ( - - ), - }); - } - // v1.29 — fluid intake strip tile. Neutral sentiment: more water is - // not unambiguously "better" (over-hydration is a real risk), so no - // up/down framing — same posture as wrist temperature / body water. - if (showWaterIntakeTile) { - trendCards.push({ - id: "waterIntake", - order: widgetOrder("waterIntake"), - node: ( - - ), - }); - } - if (showBpInTargetTile) { - /* v1.4.22 — feature parity with every other tile. - Synthesise a slope from the difference between the 7-day - and 30-day in-target shares: when the recent week is - above the recent month, the metric is improving (up-good - ⇒ green arrow); when below, it's slipping. The - trend7Delta is the same number as the arrow's underlying - signal, surfaced as "(+5)" next to `7d:` so the tile - matches the (weight / BP / pulse) call-site contract. - Comparison overlay routes through the same global - `compareBaseline` / `tileCompareDelta` pipeline as every - other tile; we only have a single % series for the BP - tile (no DataSummary) so the prior-period delta uses - `bpInTargetPctAllTime` as the long-arc baseline — when - comparison is off the field stays null. */ - // v1.4.28 — rewrite the BD-Zielbereich tile - // against the shared `` primitive so it matches - // the Weight / BP / Pulse sibling tiles exactly. The - // synthetic `bpSlope30 = bpTrendDelta / 30` block produced a - // small fractional float that the TrendCard's date-shaped - // formatter pipeline rendered as "1.1." — the regression the - // maintainer flagged in the post-v1.4.27 walk-through. The - // all-time aggregate moves to the Insights blood-pressure - // target panel which already shows the same number with more - // context; the dashboard tile no longer needs to carry it. - // `avgAllTime` - // also retires from the TrendCard API (this was its only - // consumer). - 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 = - compareBaseline === "lastMonth" - ? bpPriorMonth - : compareBaseline === "lastYear" - ? bpPriorYear - : null; - const bpCompareDelta = - compareBaseline === "none" || - bp30 === null || - bpComparePrior === null - ? null - : Math.round((bp30 - bpComparePrior) * 10) / 10; - // v1.17 — thin-data gate + dynamic span label. Below the - // confidence floor the tile narrates "collecting data" instead of - // a hard percentage, and the label names the EFFECTIVE span - // ("· 23 T" until ~90 days of history exist) rather than a - // dishonest static "· 90 T". When the count is unknown (snapshot - // still on the slim phase) the tile keeps the legacy 90-day label - // and shows whatever percentage arrived. - const bpCount90 = data?.bpInTargetCount90 ?? null; - const bpSpanDays90 = data?.bpInTargetSpanDays90 ?? null; - const bpSufficient = - bpCount90 === null || isWindowSufficient(bpCount90); - const bpLabel = - bpSpanDays90 != null - ? t("dashboard.bpInTargetWindowDynamic", { days: bpSpanDays90 }) - : t("dashboard.bpInTargetWindow90"); - trendCards.push({ - id: "bpInTarget", - order: widgetOrder("bpInTarget"), - node: ( - - ), - }); - } - if (showGlucoseCards) { - const glucoseOrder = widgetOrder("glucose"); - glucoseTiles.forEach(({ bucket, labelKey, summary: s }, idx) => { - trendCards.push({ - id: `glucose-${bucket}`, - // sub-order so all glucose cards stay in a block and order stable - order: glucoseOrder + idx / 1000, - node: ( - - ), - }); - }); - } - - trendCards.sort((a, b) => a.order - b.order); - - type ChartEntry = { - id: string; - order: number; - node: React.ReactNode; - /** - * Total raw readings for this metric. <5 surfaces a contextual - * "First trend after 5 readings" hint underneath the chart; - * undefined disables the hint (e.g. medications card). - */ - count?: number; - /** - * v1.16.0 — entry participates in the shared chart reveal: - * the cell holds its skeleton until every gated chart's data - * settled (or the 2 s fallback fires). Data-backed charts set - * this; the self-skeletoning achievements / recent-workouts - * cards do not. - */ - revealGated?: boolean; - }; - const charts: ChartEntry[] = []; - if (showWeightChart) { - charts.push({ - id: "weight-chart", - order: widgetOrder("weight"), - count: w?.count ?? 0, - revealGated: true, - node: ( - markChartReady("weight-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="weight" - types={["WEIGHT"]} - title={t("dashboard.weight")} - colors={["var(--chart-1)"]} - unit={unitDisplay.unitFor("WEIGHT")} - valueBands={weightDisplayBands} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - valueScale={unitDisplay.transformFor("WEIGHT").factor} - dayLinks - /> - ), - }); - if (user?.heightCm) { - charts.push({ - id: "bmi-chart", - order: widgetOrder("weight") + 0.5, - revealGated: true, - node: ( - markChartReady("bmi-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bmi" - types={["WEIGHT"]} - title={t("targets.bmi")} - colors={["var(--dracula-yellow)"]} - unit="kg/m²" - valueMode="bmi" - valueBands={[ - { - 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, - }, - ]} - compareBaseline={compareBaseline} - dayLinks - /> - ), - }); - } - } - if (showBpCharts) { - charts.push({ - id: "bp-chart", - order: widgetOrder("bp"), - count: Math.max(sys?.count ?? 0, dia?.count ?? 0), - revealGated: true, - node: ( - markChartReady("bp-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bp" - types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} - title={t("dashboard.bloodPressure")} - colors={["var(--chart-3)", "var(--chart-4)"]} - unit="mmHg" - targetZones={bpTargetZones} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - dayLinks - /> - ), - }); - } - if (showPulseChart) { - charts.push({ - id: "pulse-chart", - order: widgetOrder("pulse"), - count: p?.count ?? 0, - revealGated: true, - node: ( - markChartReady("pulse-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="pulse" - // v1.15.12 — chart the RESTING series against the - // resting band when available; otherwise chart raw heart - // rate WITHOUT the resting-band overlay (it would mark - // expected-high workout HR as "outside target"). - types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]} - title={pulseTitle} - colors={["var(--success)"]} - unit="bpm" - valueBands={hasRestingHr ? pulseBands : undefined} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - dayLinks - /> - ), - }); - } - if (showBodyFatChart) { - charts.push({ - id: "bodyFat-chart", - order: widgetOrder("bodyFat"), - count: bf?.count ?? 0, - revealGated: true, - node: ( - markChartReady("bodyFat-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bodyFat" - types={["BODY_FAT"]} - title={t("dashboard.bodyFat")} - colors={["var(--warning)"]} - unit="%" - valueBands={bodyFatBands} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - dayLinks - /> - ), - }); - } - if (showMoodChart) { - charts.push({ - id: "mood-chart", - order: widgetOrder("mood"), - count: moodSummary?.count ?? 0, - revealGated: true, - node: ( - markChartReady("mood-chart")} - compareBaseline={compareBaseline} - chartKey="mood" - dayLinks - /> - ), - }); - } - if (showSleepChart) { - charts.push({ - id: "sleep-chart", - order: widgetOrder("sleep"), - count: sleepSummary?.count ?? 0, - revealGated: true, - node: ( - markChartReady("sleep-chart")} - chartKey="sleep" - types={["SLEEP_DURATION"]} - title={t("dashboard.sleep") ?? "Sleep"} - colors={["var(--chart-4)"]} - unit="h" - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - dayLinks - /> - ), - }); - } - if (showStepsChart) { - charts.push({ - id: "steps-chart", - order: widgetOrder("steps"), - count: stepsSummary?.count ?? 0, - revealGated: true, - node: ( - markChartReady("steps-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="steps" - types={["ACTIVITY_STEPS"]} - title={t("dashboard.steps") ?? "Steps"} - colors={["var(--success)"]} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - dayLinks - /> - ), - }); - } - if (showMedicationsCard) { - // v1.4.15: the toggle existed since v1.1 but the dashboard - // slot only rendered a static placeholder (icon + title), so - // flipping the layout switch on did nothing visible. Wire the - // real chart that consumes - // `/api/medications/intake?scope=compliance&days=N`. - charts.push({ - id: "medications", - order: widgetOrder("medications"), - revealGated: true, - node: ( - markChartReady("medications")} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - dayLinks - /> - ), - }); - } - if (showVorsorgeCard) { - // v1.18.2 — Vorsorge preventive-care summary card. Slotted via - // the layout `order` like medications; self-fetches its reminder - // list and self-skeletons, so it stays out of the reveal gate - // (no chart-shaped data-ready signal). - charts.push({ - id: "vorsorge", - order: widgetOrder("vorsorge"), - node: , - }); - } - if (showAchievementsCard) { - // v1.4.15 — slotted at the user's chosen position via - // the layout `order`. Default order from - // `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row) - // which matches the maintainer's brief "below the chart row". - charts.push({ - id: "achievements", - order: widgetOrder("achievements"), - node: , - }); - } - if (showRecentWorkoutsTile) { - // v1.4.32 — slotted via the same `order` mechanism. Self-gates - // on the workouts query response inside the tile itself. - charts.push({ - id: "recentWorkouts", - order: widgetOrder("recentWorkouts"), - node: , - }); - } - - charts.sort((a, b) => a.order - b.order); - - // v1.4.43 — tile-strip skeleton during slow slim-analytics - // fetches. - // - // `trendCards` only fills once the slim `/api/analytics` slice - // resolves and reports per-type `count > 0` flags. The v1.4.39.2 - // slim/thick split keeps the strip painting fast when slim wins, - // but when *both* slices lag (cache eviction, cold start) the - // user used to see the page header + 0 tiles + then 7 tiles - // appear at once 9 s later. The audit recommendation is to - // render a layout-stable tile silhouette keyed off the user's - // configured tile count so the strip's footprint is reserved - // during the slow window. The skeleton swaps in for the real - // strip the moment `analyticsSlimQuery.isLoading` flips false. - // v1.7.0 — count only WEB-known tiles. The stored layout now - // round-trips the 11 iOS-only ids (so the native client can drop - // its merge workarounds), but the web dashboard has no tile - // component for them; including them here would over-reserve the - // skeleton silhouette by rows that never paint. - // v1.16.8 — narrowed further to the tile-CAPABLE set (see - // `resolveConfiguredTileCount`): the old count included - // `medications` + `recentWorkouts` (chart-row cards with no - // strip tile) and undercounted `bp` (one widget id, two tiles), - // so the silhouettes reshuffled when the data landed. - const configuredTileCount = resolveConfiguredTileCount(layout); - // `primaryLoading` is derived once in the component body (above - // the return) — the hero band and this strip share the same - // gate so both surfaces swap in the same render pass. - // v1.16.8 — chart-row reservation while the snapshot is in - // flight. The chart visibility gates above need snapshot data - // (`count > 0`), so the cold chart row only carried the - // layout-gated medications card and then grew by ~1000 px when - // the snapshot landed. Reserve layout-stable ChartSkeleton - // cells for the charts the layout expects (minus the ones - // already mounted) so the page holds roughly its final - // footprint from first paint and the snapshot landing swaps - // content instead of growing the page. - // `mounted &&` pins the hydration render to the SSR output: the - // auth query is fired by the early-hydrating shell, so `user` - // can already carry a heightCm when this late-hydrating page - // boundary replays its first render — the extra BMI silhouette - // then mismatched the server HTML (React #418). The count may - // only personalise from the first client re-render. - const chartRowPlaceholderCount = primaryLoading - ? Math.max( - 0, - resolveChartRowPlaceholderCount(layout, { - hasHeightCm: mounted && Boolean(user?.heightCm), - }) - charts.length, - ) - : 0; - const { showTileStripSkeleton, showEmptyState } = - resolveDashboardFirstPaintGate({ - trendCardCount: trendCards.length, - chartCount: charts.length, - configuredTileCount, - primaryLoading, - }); - - // v1.4.15: dashboard fully-empty state. When no tile - // and no chart has data the dashboard would otherwise paint a - // 0-px tile strip with the welcome banner above it — visually - // looked like a half-broken page. Render an EmptyState that - // re-uses the existing quick-entry dialog so the user has a - // single click into "Log measurement" without leaving the page. - // The GettingStartedChecklist above renders its own self-gated - // surface for very-new accounts; this empty state covers the - // case where the checklist has been dismissed but no data was - // logged afterwards. - // - // v1.4.43 — the empty-state only fires once the slim slice - // resolves with no tiles to show. While slim is in flight the - // skeleton strip below carries the layout footprint. - if (showEmptyState) { - return ( - } - title={t("dashboard.emptyTitle")} - description={t("dashboard.emptyDescription")} - action={ - canAddMeasurement ? ( - - ) : 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/dashboard/__tests__/tile-destinations.test.ts b/src/components/dashboard/__tests__/tile-destinations.test.ts index 2693972f3..6ba6c3244 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 { readSource } from "@/__tests__/helpers/source-files"; +import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles"; + import { TILE_CAPABLE_WIDGET_IDS, type TileCapableWidgetId, @@ -73,15 +77,18 @@ 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, read from the registry's card definitions: + * a template id (one card per glucose bucket) comes back as its prefix and a + * `*`. + */ function renderedTileIds(): string[] { - const source = readFileSync( - path.join(ROOT, "src/app/page-client.tsx"), - "utf8", + const source = readSource( + "src/components/dashboard/tiles/strip-tiles.tsx", + "STRIP_TILES", ); const ids: string[] = []; - const pattern = - /trendCards\.push\(\{\s*id:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g; + const pattern = /\bid:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g; for (const match of source.matchAll(pattern)) { ids.push(match[1] ?? `${match[2]}*`); } @@ -95,6 +102,11 @@ 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) { @@ -107,10 +119,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..1c4116373 --- /dev/null +++ b/src/components/dashboard/tiles/chart-tiles.tsx @@ -0,0 +1,386 @@ +"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; + +/** 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) => ( + ctx.markChartReady("weight-chart")} + preloadedSeries={ctx.preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="weight" + types={["WEIGHT"]} + title={ctx.t("dashboard.weight")} + colors={["var(--chart-1)"]} + unit={ctx.unitDisplay.unitFor("WEIGHT")} + valueBands={ctx.weightDisplayBands} + compareBaseline={ctx.compareBaseline} + userTimezone={ctx.user?.timezone} + valueScale={ctx.unitDisplay.transformFor("WEIGHT").factor} + dayLinks + /> + ), + }, + { + // 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) => ( + ctx.markChartReady("bmi-chart")} + preloadedSeries={ctx.preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="bmi" + types={["WEIGHT"]} + title={ctx.t("targets.bmi")} + colors={["var(--dracula-yellow)"]} + unit="kg/m²" + valueMode="bmi" + valueBands={BMI_BANDS} + compareBaseline={ctx.compareBaseline} + dayLinks + /> + ), + }, + { + 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) => ( + ctx.markChartReady("bp-chart")} + preloadedSeries={ctx.preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + 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} + compareBaseline={ctx.compareBaseline} + userTimezone={ctx.user?.timezone} + dayLinks + /> + ), + }, + { + // 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) => ( + ctx.markChartReady("pulse-chart")} + preloadedSeries={ctx.preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + 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} + compareBaseline={ctx.compareBaseline} + userTimezone={ctx.user?.timezone} + dayLinks + /> + ), + }, + { + 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) => ( + ctx.markChartReady("bodyFat-chart")} + preloadedSeries={ctx.preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="bodyFat" + types={["BODY_FAT"]} + title={ctx.t("dashboard.bodyFat")} + colors={["var(--warning)"]} + unit="%" + valueBands={ctx.bodyFatBands} + compareBaseline={ctx.compareBaseline} + userTimezone={ctx.user?.timezone} + dayLinks + /> + ), + }, + { + 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) => ( + ctx.markChartReady("sleep-chart")} + chartKey="sleep" + types={["SLEEP_DURATION"]} + title={ctx.t("dashboard.sleep") ?? "Sleep"} + colors={["var(--chart-4)"]} + unit="h" + compareBaseline={ctx.compareBaseline} + userTimezone={ctx.user?.timezone} + dayLinks + /> + ), + }, + { + 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) => ( + ctx.markChartReady("steps-chart")} + preloadedSeries={ctx.preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="steps" + types={["ACTIVITY_STEPS"]} + title={ctx.t("dashboard.steps") ?? "Steps"} + colors={["var(--success)"]} + compareBaseline={ctx.compareBaseline} + userTimezone={ctx.user?.timezone} + dayLinks + /> + ), + }, + { + // 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..6ee5dc19e --- /dev/null +++ b/src/components/dashboard/tiles/strip-tiles.tsx @@ -0,0 +1,657 @@ +"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 { 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", + }; +} + +/** + * A tile for a plain metric: the headline, its 7- and 30-day means, the trend + * and the comparison delta, nothing else. `display` names the type whose + * metric / imperial transform applies; without it the values pass through. + */ +function plainCard( + ctx: DashboardTileContext, + id: string, + props: { + type: string; + label: string; + unit: string; + icon: React.ComponentType<{ className?: string }>; + display?: boolean; + fractionDigits?: number; + staleType?: string | null; + }, +): ReactNode { + const s = 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; + return ( + + ); +} + +/** + * 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[] = [ + { + widget: "weight", + visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0, + cards: (ctx) => { + const w = ctx.summaries?.WEIGHT; + const unit = ctx.unitDisplay.unitFor("WEIGHT"); + const hint = getRangeHint( + unit, + { range: ctx.weightHintRange }, + ctx.t, + ctx.fmt.number, + ); + return [ + { + id: "weight", + node: ( + + ), + }, + ]; + }, + }, + { + // 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", + type: "BLOOD_PRESSURE_SYS", + label: ctx.t("dashboard.bloodPressureSysShort"), + range: ctx.bpSysRange, + orderOffset: 0, + }, + { + id: "bp-dia", + type: "BLOOD_PRESSURE_DIA", + label: ctx.t("dashboard.bloodPressureDiaShort"), + range: ctx.bpDiaRange, + orderOffset: 0.001, + }, + ].map(({ id, type, label, range, orderOffset }) => { + const s = ctx.summaries?.[type]; + return { + id, + orderOffset, + node: ( + + ), + }; + }), + }, + { + widget: "pulse", + visible: (ctx) => + countOf(ctx.summaries?.PULSE) > 0 || + countOf(ctx.summaries?.RESTING_HEART_RATE) > 0, + cards: (ctx) => { + const { hasRestingHr, summary: s, type } = pulseSource(ctx); + const range = hasRestingHr ? ctx.pulseDisplayRange : null; + return [ + { + id: "pulse", + node: ( + + ), + }, + ]; + }, + }, + { + widget: "bodyFat", + visible: (ctx) => countOf(ctx.summaries?.BODY_FAT) > 0, + cards: (ctx) => [ + { + id: "bodyFat", + node: plainCard(ctx, "bodyFat", { + type: "BODY_FAT", + label: ctx.t("dashboard.bodyFatShort"), + unit: "%", + icon: Percent, + }), + }, + ], + }, + { + widget: "mood", + visible: (ctx) => countOf(ctx.moodSummary) > 0, + cards: (ctx) => { + const s = ctx.moodSummary; + return [ + { + id: "mood", + node: ( + + ), + }, + ]; + }, + }, + { + widget: "sleep", + visible: (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0, + cards: (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 s = raw ? toHoursSummary(raw) : undefined; + const discrepancy = ctx.data?.sleepSourceDiscrepancy; + return [ + { + id: "sleep", + node: ( + + ) : null + } + /> + ), + }, + ]; + }, + }, + { + // Steps are activity data tied to the workouts module; only an explicit + // `false` hides them (fail-open). + widget: "steps", + visible: (ctx) => + countOf(ctx.summaries?.ACTIVITY_STEPS) > 0 && + ctx.user?.modules?.workouts !== false, + cards: (ctx) => [ + { + id: "steps", + node: plainCard(ctx, "steps", { + type: "ACTIVITY_STEPS", + label: ctx.t("dashboard.stepsShort") ?? "Steps", + unit: "", + icon: Footprints, + fractionDigits: 0, + }), + }, + ], + }, + { + widget: "vo2Max", + visible: (ctx) => countOf(ctx.summaries?.VO2_MAX) > 0, + cards: (ctx) => [ + { + id: "vo2Max", + node: plainCard(ctx, "vo2Max", { + type: "VO2_MAX", + 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. + widget: "hrv", + visible: (ctx) => countOf(ctx.hrv.summary) > 0, + cards: (ctx) => { + const { summary: s, type, labelKey } = ctx.hrv; + return [ + { + id: "hrv", + node: ( + + ), + }, + ]; + }, + }, + { + widget: "oxygenSaturation", + visible: (ctx) => countOf(ctx.summaries?.OXYGEN_SATURATION) > 0, + cards: (ctx) => [ + { + id: "oxygenSaturation", + node: plainCard(ctx, "oxygenSaturation", { + type: "OXYGEN_SATURATION", + label: ctx.t("measurements.typeOxygenSaturation"), + unit: "%", + icon: Droplets, + }), + }, + ], + }, + { + widget: "respiratoryRate", + visible: (ctx) => countOf(ctx.summaries?.RESPIRATORY_RATE) > 0, + cards: (ctx) => [ + { + id: "respiratoryRate", + node: plainCard(ctx, "respiratoryRate", { + type: "RESPIRATORY_RATE", + label: ctx.t("measurements.typeRespiratoryRate"), + unit: ctx.t("insights.units.respiratoryRate"), + icon: Wind, + }), + }, + ], + }, + { + widget: "wristTemperature", + visible: (ctx) => countOf(ctx.summaries?.WRIST_TEMPERATURE) > 0, + cards: (ctx) => [ + { + id: "wristTemperature", + node: plainCard(ctx, "wristTemperature", { + type: "WRIST_TEMPERATURE", + label: ctx.t("measurements.typeWristTemperature"), + unit: ctx.unitDisplay.unitFor("WRIST_TEMPERATURE"), + icon: Thermometer, + display: true, + }), + }, + ], + }, + { + widget: "muscleMass", + visible: (ctx) => countOf(ctx.summaries?.MUSCLE_MASS) > 0, + cards: (ctx) => [ + { + id: "muscleMass", + node: plainCard(ctx, "muscleMass", { + type: "MUSCLE_MASS", + label: ctx.t("measurements.typeMuscleMass"), + unit: ctx.unitDisplay.unitFor("MUSCLE_MASS"), + icon: Dumbbell, + display: true, + }), + }, + ], + }, + { + widget: "totalBodyWater", + visible: (ctx) => countOf(ctx.summaries?.TOTAL_BODY_WATER) > 0, + cards: (ctx) => [ + { + id: "totalBodyWater", + node: plainCard(ctx, "totalBodyWater", { + type: "TOTAL_BODY_WATER", + label: ctx.t("measurements.typeTotalBodyWater"), + unit: ctx.unitDisplay.unitFor("TOTAL_BODY_WATER"), + icon: Droplet, + display: true, + }), + }, + ], + }, + { + widget: "boneMass", + visible: (ctx) => countOf(ctx.summaries?.BONE_MASS) > 0, + cards: (ctx) => [ + { + id: "boneMass", + node: plainCard(ctx, "boneMass", { + type: "BONE_MASS", + 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. + widget: "waterIntake", + visible: (ctx) => countOf(ctx.summaries?.NUTRIENT_WATER) > 0, + cards: (ctx) => [ + { + id: "waterIntake", + node: plainCard(ctx, "waterIntake", { + type: "NUTRIENT_WATER", + 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/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts index 5241c11c9..96ee1ad4b 100644 --- a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts +++ b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts @@ -1,3 +1,4 @@ +import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; import { describe, it, expect, vi } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -151,10 +152,7 @@ describe("dashboard layout save — every layout source is covered (source pins) }); it("the dashboard reads layout from the snapshot (or the dashboardWidgets cell) only", () => { - const pageSrc = readFileSync( - join(process.cwd(), "src/app/page-client.tsx"), - "utf8", - ); + const pageSrc = dashboardSource(); // Snapshot path: layout comes off the snapshot payload… expect(pageSrc).toContain("snapshotQuery.data?.layout"); // …legacy path: the dashboardWidgets factory key (no bare literal). From a812a902dd9e691a40e86a9c582078d9d20ec92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:14:47 +0200 Subject: [PATCH 02/35] refactor(dashboard): build tiles and charts from shared card builders Most strip tiles differ only in their series, label, unit and icon, and most chart cards share the reveal report, the batched slice, the time zone and the day doors. One metric-card and one chart builder carry the shared props, so each registry entry states only what is its own. The dashboard route's client code shrinks by 0.2 KB gz; rendering is unchanged. The tile-destination guard now builds every strip tile from the registry instead of reading ids out of the source. --- .../display-transform-rounding-guard.test.ts | 5 +- .../__tests__/tile-destinations.test.ts | 47 +- .../dashboard/tiles/chart-tiles.tsx | 225 +++---- .../dashboard/tiles/strip-tiles.tsx | 618 +++++++----------- 4 files changed, 369 insertions(+), 526 deletions(-) diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index be6777ef7..f6d32f37a 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -204,10 +204,11 @@ describe("display-transform rounding guard — the bypass freeze", () => { // The justification for holding the raw multiplier at all: the chart // takes it as a prop. A file that stops passing it has no reason to be // reading `.factor` any more and belongs back behind the helpers. - // The prop as it is actually passed — a bare substring would still + // The prop as it is actually passed (as a JSX attribute or as a key of + // the props a chart builder spreads) — a bare substring would still // match a renamed identifier that no longer reaches any chart. const offenders = RAW_SCALE_SURFACES.filter( - (rel) => !/\bvalueScale=/.test(read(rel)), + (rel) => !/\bvalueScale[=:]/.test(read(rel)), ); expect(offenders).toEqual([]); }); diff --git a/src/components/dashboard/__tests__/tile-destinations.test.ts b/src/components/dashboard/__tests__/tile-destinations.test.ts index 6ba6c3244..7d2224258 100644 --- a/src/components/dashboard/__tests__/tile-destinations.test.ts +++ b/src/components/dashboard/__tests__/tile-destinations.test.ts @@ -14,8 +14,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; -import { readSource } from "@/__tests__/helpers/source-files"; import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles"; +import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context"; import { TILE_CAPABLE_WIDGET_IDS, @@ -78,21 +78,37 @@ const NON_MEASUREMENT_DESTINATION: Partial< const NO_DESTINATION: Partial> = {}; /** - * Card ids the strip tiles paint, read from the registry's card definitions: - * a template id (one card per glucose bucket) comes back as its prefix and a - * `*`. + * 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 = readSource( - "src/components/dashboard/tiles/strip-tiles.tsx", - "STRIP_TILES", - ); - const ids: string[] = []; - const pattern = /\bid:\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", () => { @@ -110,8 +126,7 @@ describe("dashboard tile destinations", () => { 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); } diff --git a/src/components/dashboard/tiles/chart-tiles.tsx b/src/components/dashboard/tiles/chart-tiles.tsx index 1c4116373..bf05e10bd 100644 --- a/src/components/dashboard/tiles/chart-tiles.tsx +++ b/src/components/dashboard/tiles/chart-tiles.tsx @@ -110,6 +110,38 @@ const BMI_BANDS = [ 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[] = [ { @@ -118,24 +150,16 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0, count: (ctx) => ctx.summaries?.WEIGHT?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("weight-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="weight" - types={["WEIGHT"]} - title={ctx.t("dashboard.weight")} - colors={["var(--chart-1)"]} - unit={ctx.unitDisplay.unitFor("WEIGHT")} - valueBands={ctx.weightDisplayBands} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - valueScale={ctx.unitDisplay.transformFor("WEIGHT").factor} - dayLinks - /> - ), + 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 @@ -146,23 +170,21 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0 && Boolean(ctx.user?.heightCm), - render: (ctx) => ( - ctx.markChartReady("bmi-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bmi" - types={["WEIGHT"]} - title={ctx.t("targets.bmi")} - colors={["var(--dracula-yellow)"]} - unit="kg/m²" - valueMode="bmi" - valueBands={BMI_BANDS} - compareBaseline={ctx.compareBaseline} - dayLinks - /> - ), + 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", @@ -176,23 +198,15 @@ export const CHART_TILES: readonly ChartTile[] = [ ctx.summaries?.BLOOD_PRESSURE_SYS?.count ?? 0, ctx.summaries?.BLOOD_PRESSURE_DIA?.count ?? 0, ), - render: (ctx) => ( - ctx.markChartReady("bp-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - 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} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + 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; @@ -205,27 +219,17 @@ export const CHART_TILES: readonly ChartTile[] = [ countOf(ctx.summaries?.PULSE) > 0 || countOf(ctx.summaries?.RESTING_HEART_RATE) > 0, count: (ctx) => ctx.summaries?.PULSE?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("pulse-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - 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} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + 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", @@ -233,23 +237,15 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.BODY_FAT) > 0, count: (ctx) => ctx.summaries?.BODY_FAT?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("bodyFat-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bodyFat" - types={["BODY_FAT"]} - title={ctx.t("dashboard.bodyFat")} - colors={["var(--warning)"]} - unit="%" - valueBands={ctx.bodyFatBands} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + 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", @@ -274,20 +270,19 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0, count: (ctx) => ctx.summaries?.SLEEP_DURATION?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("sleep-chart")} - chartKey="sleep" - types={["SLEEP_DURATION"]} - title={ctx.t("dashboard.sleep") ?? "Sleep"} - colors={["var(--chart-4)"]} - unit="h" - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + 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", @@ -295,21 +290,13 @@ export const CHART_TILES: readonly ChartTile[] = [ revealGated: true, visible: (ctx) => countOf(ctx.summaries?.ACTIVITY_STEPS) > 0, count: (ctx) => ctx.summaries?.ACTIVITY_STEPS?.count ?? 0, - render: (ctx) => ( - ctx.markChartReady("steps-chart")} - preloadedSeries={ctx.preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="steps" - types={["ACTIVITY_STEPS"]} - title={ctx.t("dashboard.steps") ?? "Steps"} - colors={["var(--success)"]} - compareBaseline={ctx.compareBaseline} - userTimezone={ctx.user?.timezone} - dayLinks - /> - ), + 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`. diff --git a/src/components/dashboard/tiles/strip-tiles.tsx b/src/components/dashboard/tiles/strip-tiles.tsx index 6ee5dc19e..d45321c39 100644 --- a/src/components/dashboard/tiles/strip-tiles.tsx +++ b/src/components/dashboard/tiles/strip-tiles.tsx @@ -32,6 +32,7 @@ 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"; @@ -80,12 +81,18 @@ function pulseSource(ctx: DashboardTileContext) { }; } +type Summary = NonNullable[string]; +type TrafficRange = Parameters[1]["range"]; + /** - * A tile for a plain metric: the headline, its 7- and 30-day means, the trend - * and the comparison delta, nothing else. `display` names the type whose - * metric / imperial transform applies; without it the values pass through. + * 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 plainCard( +function metricCard( ctx: DashboardTileContext, id: string, props: { @@ -93,16 +100,30 @@ function plainCard( 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 = ctx.summaries?.[props.type]; + 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[] = [ - { - widget: "weight", - visible: (ctx) => countOf(ctx.summaries?.WEIGHT) > 0, - cards: (ctx) => { - const w = ctx.summaries?.WEIGHT; - const unit = ctx.unitDisplay.unitFor("WEIGHT"); - const hint = getRangeHint( - unit, - { range: ctx.weightHintRange }, - ctx.t, - ctx.fmt.number, - ); - return [ - { - id: "weight", - node: ( - - ), - }, - ]; - }, - }, + 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 @@ -183,360 +208,175 @@ export const STRIP_TILES: readonly StripTile[] = [ visible: (ctx) => countOf(ctx.summaries?.BLOOD_PRESSURE_SYS) > 0 || countOf(ctx.summaries?.BLOOD_PRESSURE_DIA) > 0, - cards: (ctx) => - [ - { - id: "bp-sys", + 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, - orderOffset: 0, - }, - { - id: "bp-dia", + }), + }, + { + 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, - orderOffset: 0.001, - }, - ].map(({ id, type, label, range, orderOffset }) => { - const s = ctx.summaries?.[type]; - return { - id, - orderOffset, - node: ( - - ), - }; - }), + }), + }, + ], }, - { - widget: "pulse", - visible: (ctx) => + one( + "pulse", + (ctx) => countOf(ctx.summaries?.PULSE) > 0 || countOf(ctx.summaries?.RESTING_HEART_RATE) > 0, - cards: (ctx) => { - const { hasRestingHr, summary: s, type } = pulseSource(ctx); - const range = hasRestingHr ? ctx.pulseDisplayRange : null; - return [ - { - id: "pulse", - node: ( - - ), - }, - ]; + (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, + }); }, - }, - { - widget: "bodyFat", - visible: (ctx) => countOf(ctx.summaries?.BODY_FAT) > 0, - cards: (ctx) => [ - { - id: "bodyFat", - node: plainCard(ctx, "bodyFat", { - type: "BODY_FAT", - label: ctx.t("dashboard.bodyFatShort"), - unit: "%", - icon: Percent, - }), - }, - ], - }, - { - widget: "mood", - visible: (ctx) => countOf(ctx.moodSummary) > 0, - cards: (ctx) => { - const s = ctx.moodSummary; - return [ - { - id: "mood", - node: ( - - ), - }, - ]; - }, - }, - { - widget: "sleep", - visible: (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0, - cards: (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. + ), + 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 s = raw ? toHoursSummary(raw) : undefined; const discrepancy = ctx.data?.sleepSourceDiscrepancy; - return [ - { - id: "sleep", - node: ( - - ) : null - } - /> - ), - }, - ]; + 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). - widget: "steps", - visible: (ctx) => + ), + // 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, - cards: (ctx) => [ - { - id: "steps", - node: plainCard(ctx, "steps", { - type: "ACTIVITY_STEPS", - label: ctx.t("dashboard.stepsShort") ?? "Steps", - unit: "", - icon: Footprints, - fractionDigits: 0, - }), - }, - ], - }, - { - widget: "vo2Max", - visible: (ctx) => countOf(ctx.summaries?.VO2_MAX) > 0, - cards: (ctx) => [ - { - id: "vo2Max", - node: plainCard(ctx, "vo2Max", { - type: "VO2_MAX", - 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. - widget: "hrv", - visible: (ctx) => countOf(ctx.hrv.summary) > 0, - cards: (ctx) => { - const { summary: s, type, labelKey } = ctx.hrv; - return [ - { - id: "hrv", - node: ( - - ), - }, - ]; - }, - }, - { - widget: "oxygenSaturation", - visible: (ctx) => countOf(ctx.summaries?.OXYGEN_SATURATION) > 0, - cards: (ctx) => [ - { - id: "oxygenSaturation", - node: plainCard(ctx, "oxygenSaturation", { - type: "OXYGEN_SATURATION", - label: ctx.t("measurements.typeOxygenSaturation"), - unit: "%", - icon: Droplets, - }), - }, - ], - }, - { - widget: "respiratoryRate", - visible: (ctx) => countOf(ctx.summaries?.RESPIRATORY_RATE) > 0, - cards: (ctx) => [ - { - id: "respiratoryRate", - node: plainCard(ctx, "respiratoryRate", { - type: "RESPIRATORY_RATE", - label: ctx.t("measurements.typeRespiratoryRate"), - unit: ctx.t("insights.units.respiratoryRate"), - icon: Wind, - }), - }, - ], - }, - { - widget: "wristTemperature", - visible: (ctx) => countOf(ctx.summaries?.WRIST_TEMPERATURE) > 0, - cards: (ctx) => [ - { - id: "wristTemperature", - node: plainCard(ctx, "wristTemperature", { - type: "WRIST_TEMPERATURE", - label: ctx.t("measurements.typeWristTemperature"), - unit: ctx.unitDisplay.unitFor("WRIST_TEMPERATURE"), - icon: Thermometer, - display: true, - }), - }, - ], - }, - { - widget: "muscleMass", - visible: (ctx) => countOf(ctx.summaries?.MUSCLE_MASS) > 0, - cards: (ctx) => [ - { - id: "muscleMass", - node: plainCard(ctx, "muscleMass", { - type: "MUSCLE_MASS", - label: ctx.t("measurements.typeMuscleMass"), - unit: ctx.unitDisplay.unitFor("MUSCLE_MASS"), - icon: Dumbbell, - display: true, - }), - }, - ], - }, - { - widget: "totalBodyWater", - visible: (ctx) => countOf(ctx.summaries?.TOTAL_BODY_WATER) > 0, - cards: (ctx) => [ - { - id: "totalBodyWater", - node: plainCard(ctx, "totalBodyWater", { - type: "TOTAL_BODY_WATER", - label: ctx.t("measurements.typeTotalBodyWater"), - unit: ctx.unitDisplay.unitFor("TOTAL_BODY_WATER"), - icon: Droplet, - display: true, - }), - }, - ], - }, - { - widget: "boneMass", - visible: (ctx) => countOf(ctx.summaries?.BONE_MASS) > 0, - cards: (ctx) => [ - { - id: "boneMass", - node: plainCard(ctx, "boneMass", { - type: "BONE_MASS", - 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. - widget: "waterIntake", - visible: (ctx) => countOf(ctx.summaries?.NUTRIENT_WATER) > 0, - cards: (ctx) => [ - { - id: "waterIntake", - node: plainCard(ctx, "waterIntake", { - type: "NUTRIENT_WATER", - label: ctx.t("nutrients.names.water"), - unit: "mL", - icon: GlassWater, - }), - }, - ], - }, + (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 From 5075536f7f17738e46dccfa1cf008e0a99dcd249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:25:16 +0200 Subject: [PATCH 03/35] refactor(charts): split HealthChart into its read, view, layers and header HealthChart was a 1,929-line component. Its parts move to components/charts/health-chart/: - series-math: series names, day keys, moving average, personal baseline, annotation and marker placement (pure) - use-health-chart-series: the fetch window, the batched-slice decision, the daily read and the data-ready latch - use-health-chart-view: the visible slice, bucketing, the average and trend lines, the prior period, the visible-range stats and the y domain - use-health-chart-overlays: value bands, target zones, baselines, the trend read-out, the spoken summary and the table columns - chart-header, band-overlay, chart-tooltip-content - chart-layers: the reference layers and series as plain functions that return the Recharts elements, so the plot's child list is unchanged health-chart.tsx keeps the component and its path (chart-runtime still imports it from there) and shrinks to 488 lines. Output is unchanged. The guards pinned to the file follow the code into the folder, and the baseline-gating check now calls the layer instead of matching source. --- eslint.config.mjs | 3 + .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 5 +- ...ate-order-explicit-formatter-guard.test.ts | 2 +- .../pulse-day-mean-reader-guard.test.ts | 2 +- .../health-chart-annotations.test.ts | 2 +- .../health-chart-overlay-defaults.test.tsx | 37 +- .../__tests__/health-chart-polish.test.tsx | 2 +- .../health-chart-target-link-removed.test.ts | 11 +- .../health-chart-type-label.test.tsx | 2 +- .../health-chart-vertical-markers.test.ts | 2 +- src/components/charts/health-chart.tsx | 2415 ++--------------- .../charts/health-chart/band-overlay.tsx | 43 + .../charts/health-chart/chart-header.tsx | 114 + .../charts/health-chart/chart-layers.tsx | 352 +++ .../health-chart/chart-tooltip-content.tsx | 158 ++ .../charts/health-chart/series-math.ts | 221 ++ src/components/charts/health-chart/types.ts | 186 ++ .../health-chart/use-health-chart-overlays.ts | 309 +++ .../health-chart/use-health-chart-series.ts | 360 +++ .../health-chart/use-health-chart-view.ts | 347 +++ .../charts/__tests__/data-ready-latch.test.ts | 2 +- src/lib/charts/__tests__/fetch-window.test.ts | 5 +- 24 files changed, 2365 insertions(+), 2224 deletions(-) create mode 100644 src/components/charts/health-chart/band-overlay.tsx create mode 100644 src/components/charts/health-chart/chart-header.tsx create mode 100644 src/components/charts/health-chart/chart-layers.tsx create mode 100644 src/components/charts/health-chart/chart-tooltip-content.tsx create mode 100644 src/components/charts/health-chart/series-math.ts create mode 100644 src/components/charts/health-chart/types.ts create mode 100644 src/components/charts/health-chart/use-health-chart-overlays.ts create mode 100644 src/components/charts/health-chart/use-health-chart-series.ts create mode 100644 src/components/charts/health-chart/use-health-chart-view.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index ce53edc99..50a13361d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -214,6 +214,9 @@ const eslintConfig = defineConfig([ "src/components/charts/chart-runtime.ts", "src/components/admin/host-metrics-chart.tsx", "src/components/charts/health-chart.tsx", + // HealthChart's plot layers, imported by health-chart.tsx alone, so + // they sit inside the same chart-runtime graph. + "src/components/charts/health-chart/chart-layers.tsx", "src/components/charts/medication-compliance-chart.tsx", "src/components/charts/mood-chart.tsx", "src/components/charts/nutrient-daily-bar-chart.tsx", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1e37872a7..3b163dee8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 121, - "total": 142301, + "count": 120, + "total": 139784, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -13,7 +13,6 @@ "app/api/workouts/batch/route.ts": 958, "app/medications/page-client.tsx": 839, "components/admin/backups-section.tsx": 1462, - "components/charts/health-chart.tsx": 2517, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, "components/documents/document-detail-sheet.tsx": 1304, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index ff79075df..53d14f450 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 408, - "total": 144450, + "total": 143009, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -97,7 +97,7 @@ "components/auth/claim-profile.tsx :: ClaimProfile": 429, "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, "components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325, - "components/charts/health-chart.tsx :: HealthChart": 1929, + "components/charts/health-chart.tsx :: HealthChart": 488, "components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543, "components/charts/mood-chart.tsx :: MoodChart": 896, "components/charts/mood-heatmap.tsx :: MoodHeatmap": 296, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 6ec71c7df..76742a6bb 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2953, - "total": 10418, + "count": 2952, + "total": 10336, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -767,7 +767,6 @@ "components/charts/chart-tooltip.tsx": 1, "components/charts/compliance-heatmap.tsx": 6, "components/charts/health-chart-dynamic.tsx": 3, - "components/charts/health-chart.tsx": 82, "components/charts/medication-compliance-chart.tsx": 21, "components/charts/mini-window.ts": 1, "components/charts/mood-chart.tsx": 33, diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index a31307fcb..8d479de9c 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -256,7 +256,7 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ why: "The same key shape for `today`, compared against those buckets. A comparison, not a label.", }, { - file: "src/components/charts/health-chart.tsx", + file: "src/components/charts/health-chart/series-math.ts", why: "`makeDayKeyFormatter` — the per-row bucketing key. Its fields are read back through `formatToParts` and reassembled as `YYYY-MM-DD`.", }, { diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 0918eda6a..bf8c215dd 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -139,7 +139,7 @@ const TS_READERS: Record = { exempt: "means several readings of a type the day route sends one by one (weight, blood pressure, glucose); pulse arrives as one server-side day value (`dayValueShape` in lib/day/values.ts)", }, - "components/charts/health-chart.tsx": { + "components/charts/health-chart/series-math.ts": { exempt: "a rolling average over the points the chart draws: display smoothing, not a stated figure", }, 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/lib/charts/__tests__/data-ready-latch.test.ts b/src/lib/charts/__tests__/data-ready-latch.test.ts index 21e9a46c7..0c310dd0d 100644 --- a/src/lib/charts/__tests__/data-ready-latch.test.ts +++ b/src/lib/charts/__tests__/data-ready-latch.test.ts @@ -66,7 +66,7 @@ describe("shouldFireDataReady", () => { describe("chart notify wiring", () => { const charts = [ - "src/components/charts/health-chart.tsx", + "src/components/charts/health-chart/use-health-chart-series.ts", "src/components/charts/mood-chart.tsx", "src/components/charts/medication-compliance-chart.tsx", ]; diff --git a/src/lib/charts/__tests__/fetch-window.test.ts b/src/lib/charts/__tests__/fetch-window.test.ts index 96287d8b6..589af8553 100644 --- a/src/lib/charts/__tests__/fetch-window.test.ts +++ b/src/lib/charts/__tests__/fetch-window.test.ts @@ -65,7 +65,10 @@ describe.each([ describe("health chart wiring", () => { it("builds its fetch window from today's key, not the mount instant", () => { const chart = readFileSync( - join(process.cwd(), "src/components/charts/health-chart.tsx"), + join( + process.cwd(), + "src/components/charts/health-chart/use-health-chart-series.ts", + ), "utf8", ); const memo = chart.slice(chart.indexOf("const fetchWindow = useMemo(")); From 3c0063edb203117db475d062ea81ad31ad12c954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:42:43 +0200 Subject: [PATCH 04/35] refactor(measurements): split the measurement list into view state, data, rows and sheet MeasurementList was a 1,586-line component with fourteen pieces of state. Its parts move to components/measurements/list/: - list-model: the row type, page size, labels and formatting helpers - use-measurement-list-filters: URL-owned filters, the value-range draft, page, sort and the page-scoped selection - use-measurement-list-data: the list read, the soft deletes with undo and the selection operations - use-measurement-edit: the edit draft, its unit conversion both ways, the save and the discard guard - use-measurement-row-display: how a row shows its reading, as a pure rule (rowDisplayFor) bound to the account - measurement-filter-rail, measurement-table (one row component), measurement-card-list (one card component), row-actions shared by both layouts, measurement-edit-sheet, day-drill-down measurement-list.tsx lays these out in about 330 lines. Markup and behaviour are unchanged. The glucose edit checks call the conversion functions instead of matching source, and the guards that named the old file name the module that now holds what they check. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 7 +- .../baselines/test-source-reads.json | 5 +- .../baselines/version-stamp-comments.json | 5 +- .../destructive-control-guard.test.ts | 7 +- .../success-affordance-guard.test.ts | 4 +- .../unit-preference-display-guard.test.ts | 9 +- .../use-seeded-form-dismissal.test.ts | 27 +- .../measurement-list-glucose-unit.test.tsx | 53 +- .../measurements/list/day-drill-down.tsx | 155 ++ .../measurements/list/list-model.ts | 141 ++ .../list/measurement-card-list.tsx | 255 +++ .../list/measurement-edit-sheet.tsx | 265 +++ .../list/measurement-filter-rail.tsx | 112 + .../measurements/list/measurement-table.tsx | 318 +++ .../measurements/list/row-actions.tsx | 89 + .../measurements/list/use-measurement-edit.ts | 268 +++ .../list/use-measurement-list-data.ts | 219 ++ .../list/use-measurement-list-filters.ts | 208 ++ .../list/use-measurement-row-display.ts | 86 + .../measurements/measurement-list.tsx | 2038 ++--------------- 21 files changed, 2389 insertions(+), 1887 deletions(-) create mode 100644 src/components/measurements/list/day-drill-down.tsx create mode 100644 src/components/measurements/list/list-model.ts create mode 100644 src/components/measurements/list/measurement-card-list.tsx create mode 100644 src/components/measurements/list/measurement-edit-sheet.tsx create mode 100644 src/components/measurements/list/measurement-filter-rail.tsx create mode 100644 src/components/measurements/list/measurement-table.tsx create mode 100644 src/components/measurements/list/row-actions.tsx create mode 100644 src/components/measurements/list/use-measurement-edit.ts create mode 100644 src/components/measurements/list/use-measurement-list-data.ts create mode 100644 src/components/measurements/list/use-measurement-list-filters.ts create mode 100644 src/components/measurements/list/use-measurement-row-display.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 3b163dee8..8f2f24790 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 120, - "total": 139784, + "count": 119, + "total": 137765, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -26,7 +26,6 @@ "components/layout/sidebar-nav.tsx": 855, "components/measurement-reminders/vorsorge-section.tsx": 1536, "components/measurements/measurement-form.tsx": 815, - "components/measurements/measurement-list.tsx": 2019, "components/medications/detail/medication-detail-tabs.tsx": 1160, "components/medications/dose-history-ledger.tsx": 1052, "components/medications/medication-table.tsx": 853, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 53d14f450..19a5f68ca 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 408, - "total": 143009, + "count": 405, + "total": 140971, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -180,9 +180,6 @@ "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeCard": 635, "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 618, "components/measurements/measurement-form.tsx :: MeasurementForm": 500, - "components/measurements/measurement-list.tsx :: MeasurementList": 1586, - "components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg)": 223, - "components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg) #2": 229, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771, "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 97d3ca3a0..91bc78a7e 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 361, - "total": 361, + "count": 360, + "total": 360, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -214,7 +214,6 @@ "components/layout/__tests__/sidebar-nav.test.tsx": 1, "components/measurements/__tests__/duration-read-as.test.tsx": 1, "components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1, - "components/measurements/__tests__/measurement-list-glucose-unit.test.tsx": 1, "components/medications/__tests__/add-inventory-dialog.test.tsx": 1, "components/medications/__tests__/detail-page-cascade.test.tsx": 1, "components/medications/__tests__/inventory-section-dates.test.tsx": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 76742a6bb..2705ce464 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2952, - "total": 10336, + "count": 2951, + "total": 10297, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1131,7 +1131,6 @@ "components/measurements/__tests__/measurement-list-locked-type.test.tsx": 2, "components/measurements/__tests__/measurement-list-step-grouping.test.tsx": 1, "components/measurements/measurement-form.tsx": 11, - "components/measurements/measurement-list.tsx": 39, "components/medications/__tests__/add-inventory-dialog.test.tsx": 1, "components/medications/__tests__/card-parts.test.tsx": 13, "components/medications/__tests__/detail-page-cascade.test.tsx": 1, diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index d95443811..d7ad190f2 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -350,8 +350,13 @@ const REGISTRY: DestructiveEntry[] = [ // ── Measurements, mood, labs, illness — the undo-and-restore surfaces ──── { - file: "components/measurements/measurement-list.tsx", + file: "components/measurements/list/use-measurement-list-data.ts", destroys: "one measurement, or every selected measurement", + triggers: [ + "components/measurements/measurement-list.tsx", + "components/measurements/list/row-actions.tsx", + "components/measurements/list/measurement-edit-sheet.tsx", + ], recovery: "restorable", confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"], }, diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 361c8afa2..f07694e03 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -233,7 +233,9 @@ const PINNED_AFFORDANCES: Record< "text-success": 1, }, "src/components/measurements/measurement-form.tsx": { "toast.success": 1 }, - "src/components/measurements/measurement-list.tsx": { "toast.success": 3 }, + "src/components/measurements/list/use-measurement-list-data.ts": { + "toast.success": 3, + }, "src/components/medications/card-parts/medication-status-pill.tsx": { "text-success": 1, }, diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index fbc72f0df..a14dbbe91 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -296,10 +296,15 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save", }, - "components/measurements/measurement-list.tsx": { - lines: 2, + "components/measurements/list/use-measurement-edit.ts": { + lines: 1, reason: "input parsing: the inline edit inverts an mmol/L entry to mg/dL", }, + "components/measurements/list/use-measurement-row-display.ts": { + lines: 1, + reason: + "input parsing: decides whether the inline edit's input is in mmol/L and so needs inverting", + }, "components/settings/import-panel/import-examples.ts": { lines: 3, reason: "input parsing: CSV examples showing the units an import accepts", diff --git a/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts b/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts index 7d499c625..3afb798c2 100644 --- a/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts +++ b/src/components/forms/__tests__/use-seeded-form-dismissal.test.ts @@ -322,11 +322,28 @@ describe("safeInternalNavigationTarget", () => { }); describe("seeded dismissal migrations", () => { - it.each([ - "measurements/measurement-list.tsx", - "mood/mood-list.tsx", - "labs/lab-history-list.tsx", - ])( + it("routes every measurement-list edit exit through the shared contract", () => { + // The list's edit lives in its own hook and sheet. + const hook = readFileSync( + new URL( + "../../measurements/list/use-measurement-edit.ts", + import.meta.url, + ), + "utf8", + ); + const sheet = readFileSync( + new URL( + "../../measurements/list/measurement-edit-sheet.tsx", + import.meta.url, + ), + "utf8", + ); + expect(hook).toContain("useSeededFormDismissal({"); + expect(hook).toContain("editDismissal.requestClose(dismissEdit)"); + expect(sheet).toContain(" { const source = readFileSync( diff --git a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx index 97765257f..ed1d056da 100644 --- a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -63,11 +61,33 @@ vi.mock("@/hooks/use-auth", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { MeasurementList } from "../measurement-list"; +import type { UnitDisplay } from "@/hooks/use-unit-display"; +import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose"; +import { getUnitForType } from "@/lib/measurements/unit-map"; +import { editSeedValue, storedValueFor } from "../list/use-measurement-edit"; +import { rowDisplayFor } from "../list/use-measurement-row-display"; -const listSource = readFileSync( - join(process.cwd(), "src/components/measurements/measurement-list.tsx"), - "utf8", -); +/** A metric reader on the given glucose unit, as the list's row display. */ +function displayOn(glucoseUnit: "mg/dL" | "mmol/L") { + const unitDisplay = { + preference: "metric", + isTransformed: () => false, + toDisplay: (_type: string, v: number) => v, + fromDisplay: (_type: string, v: number) => v, + unitFor: (type: string) => getUnitForType(type), + } as unknown as UnitDisplay; + return rowDisplayFor(glucoseUnit, unitDisplay); +} + +const glucose = (value: number) => ({ + id: "g1", + type: "BLOOD_GLUCOSE", + value, + unit: "mg/dL", + source: "MANUAL", + measuredAt: "2026-10-01T08:00:00.000Z", + notes: null, +}); function render() { return renderToStaticMarkup( @@ -96,22 +116,27 @@ describe("MeasurementList — blood-glucose display unit", () => { }); it("inverts an edited glucose value back to mg/dL before the save", () => { - expect(listSource).toMatch( - /editsConvertedGlucose\(editing\)[\s\S]{0,400}?canonicalValue = toCanonicalMgdl\(parsedValue, "mmol\/L"\)/, - ); + // The label above the input says mmol/L, so the number in it is mmol/L; + // sent on unconverted it would rewrite a 95 mg/dL reading as 5.3. + const stored = storedValueFor(displayOn("mmol/L"), glucose(95), 5.3); + expect(stored).toBe(toCanonicalMgdl(5.3, "mmol/L")); + expect(stored).toBeGreaterThan(90); }); it("seeds the edit input in the unit its own label names", () => { - expect(listSource).toMatch( - /const seedValue = editsConvertedGlucose\(measurement\)\s*\?\s*rd\.value/, + const display = displayOn("mmol/L"); + expect(editSeedValue(display, glucose(95))).toBe( + convertGlucose(95, "mmol/L"), ); + expect(display.rowDisplay(glucose(95)).unit).toBe("mmol/L"); }); it("leaves an mg/dL edit on the identity path so a note fix cannot round the value", () => { // `toCanonicalMgdl(v, "mg/dL")` rounds, so routing the mg/dL branch // through it would quantise a stored 95.48 on an unrelated edit. - expect(listSource).toMatch( - /editsConvertedGlucose[\s\S]{0,300}?glucoseUnit === "mmol\/L"/, - ); + const display = displayOn("mg/dL"); + expect(display.editsConvertedGlucose(glucose(95.48))).toBe(false); + expect(editSeedValue(display, glucose(95.48))).toBe(95.48); + expect(storedValueFor(display, glucose(95.48), 95.48)).toBe(95.48); }); }); diff --git a/src/components/measurements/list/day-drill-down.tsx b/src/components/measurements/list/day-drill-down.tsx new file mode 100644 index 000000000..1cda48d4e --- /dev/null +++ b/src/components/measurements/list/day-drill-down.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; + +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { formatDateTime } from "@/lib/format"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { sleepStageLabel, type Measurement } from "./list-model"; + +/** + * The lazy drill-down for a collapsed day or night. + * Fires a separate `GET /api/measurements?type=…&dayKey=…` only when + * the row is expanded; the result is rendered as a nested list of the + * per-sample chunks that summed into the parent row's daily total. + */ +export function DayDrillDown({ + type, + dayKey, + unit, + layout, +}: { + type: string; + dayKey: string; + unit: string; + layout: "desktop" | "mobile"; +}) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: queryKeys.measurementDrilldown(type, dayKey), + queryFn: async () => { + const params = new URLSearchParams(); + params.set("type", type); + params.set("dayKey", dayKey); + params.set("sortDir", "asc"); + return apiGet<{ measurements: Measurement[] }>( + `/api/measurements?${params}`, + ); + }, + enabled: isAuthenticated, + // The drill-down is per-day — once fetched it rarely needs to + // re-fetch (the underlying day has already stabilised for + // anything older than the drain's 36 h cutoff). Keep it cached + // for the session. + staleTime: 5 * 60 * 1000, + }); + + if (isLoading) { + return ( +
+ + {t("common.loading")} +
+ ); + } + if (error) { + return ( + void refetch()} + className={layout === "desktop" ? "m-2" : "mt-2"} + /> + ); + } + const rows = data?.measurements ?? []; + if (rows.length === 0) { + return ( + + ); + } + // For SLEEP_DURATION the drill-down rows are stage segments; + // render the stage label + minutes instead of a bare value + unit. + const isSleep = type === "SLEEP_DURATION"; + if (layout === "desktop") { + return ( +
+ {rows.map((s) => ( +
+ + {isSleep + ? sleepStageLabel(s.sleepStage, t) + : formatDateTime(s.measuredAt)} + + + {isSleep + ? formatDurationMinutes(s.value, t) + : `${s.value} ${unit}`} + +
+ ))} +
+ ); + } + return ( +
+ {rows.map((s) => ( +
+ + {isSleep + ? sleepStageLabel(s.sleepStage, t) + : formatDateTime(s.measuredAt)} + + + {isSleep ? formatDurationMinutes(s.value, t) : `${s.value} ${unit}`} + +
+ ))} +
+ ); +} + +/** + * Sub-caption for a per-night sleep row: the row's value is the day's sleep, + * main night plus naps, so the caption says how many naps it includes, and + * the night's mid-sleep awakenings when present. + */ +export function SleepNightCaption({ m }: { m: Measurement }) { + const { t } = useTranslations(); + const napCount = m.napCount ?? 0; + const awakenings = m.awakenings ?? 0; + const parts: string[] = []; + if (napCount > 0) { + parts.push(t("measurements.sleepNapCaption", { count: String(napCount) })); + } + if (awakenings > 0) { + parts.push( + t("measurements.sleepAwakeningsCaption", { count: String(awakenings) }), + ); + } + if (parts.length === 0) return null; + return ( + + {parts.join(" · ")} + + ); +} diff --git a/src/components/measurements/list/list-model.ts b/src/components/measurements/list/list-model.ts new file mode 100644 index 000000000..7fe3519b6 --- /dev/null +++ b/src/components/measurements/list/list-model.ts @@ -0,0 +1,141 @@ +import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; +import type { useTranslations } from "@/lib/i18n/context"; +import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; +import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit"; +import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources"; + +type Translate = ReturnType["t"]; + +/** + * Cumulative types whose list view collapses to one row per profile-zone day + * with a chevron into the per-sample chunks. Mirrors the server's + * `CUMULATIVE_DAY_SUM_TYPES`, so the route's `groupBy=day` branch and the + * client's filter detection agree. + */ +export const CUMULATIVE_TYPES = new Set(CUMULATIVE_DAY_SUM_TYPES); + +// Columns that open descending when first selected. The date column reads +// newest-first by default; every other column opens ascending. +export const MEASUREMENT_DESC_COLUMNS: ReadonlySet = new Set([ + "measuredAt", +]); + +export interface Measurement { + id: string; + type: string; + value: number; + unit: string; + source: string; + measuredAt: string; + notes: string | null; + /** + * Present only on collapsed daily rows (`?type=…&groupBy=day`): drives the + * expand chevron and the drill-down read keyed by the calendar day. + */ + dayKey?: string; + sampleCount?: number; + /** + * Present only on per-night SLEEP_DURATION rows. `value` is the night's + * time asleep in minutes; these carry the night's context so the row + * reads as one night, not many stage values. + */ + sleepStage?: string | null; + napCount?: number; + napAsleepMinutes?: number; + awakenings?: number; +} + +/** A synthetic day or night row: many raw rows behind one `dayKey`. */ +export function isGroupedRow(m: Measurement): boolean { + return m.dayKey !== undefined && m.sampleCount !== undefined; +} + +/** The labels for the sleep stages shown in the drill-down. */ +export function sleepStageLabel( + stage: string | null | undefined, + t: Translate, +): string { + switch (stage) { + case "DEEP": + return t("insights.sleep.stages.deep"); + case "REM": + return t("insights.sleep.stages.rem"); + case "CORE": + return t("insights.sleep.stages.core"); + case "ASLEEP": + return t("insights.sleep.stages.asleep"); + case "AWAKE": + return t("insights.sleep.stages.awake"); + case "IN_BED": + return t("insights.sleep.stages.inBed"); + default: + return stage ?? ""; + } +} + +export const PAGE_SIZE = 25; +// Input cap mirrors the server Zod bound. The list preview truncates at a +// shorter width so a long note does not stretch a list row. +export const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH; +const COMMENT_PREVIEW_LENGTH = 40; + +export function truncateComment(comment: string): string { + if (comment.length <= COMMENT_PREVIEW_LENGTH) return comment; + return `${comment.slice(0, COMMENT_PREVIEW_LENGTH - 1)}…`; +} + +export function toDateTimeLocalValue(isoString: string): string { + const date = new Date(isoString); + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60 * 1000); + return local.toISOString().slice(0, 16); +} + +/** + * The `MeasurementSource` enum through the `measurements.source*` keys, so + * the SCREAMING_SNAKE enum never reaches a cell or the filter. The catalogue + * is keyed by the enum, so a provider added without its label is a type + * error rather than a row that reads `GOOGLE_HEALTH`. + */ +export function formatMeasurementSource(source: string, t: Translate) { + const key = MEASUREMENT_SOURCE_LIST_LABEL_KEYS[ + source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS + ] as string | undefined; + // A row written by a server the client does not know yet still renders as + // its own value rather than as a missing translation key. + return key ? t(key) : source; +} + +/** The sources the filter offers, from the shared Zod enum. */ +export const MEASUREMENT_SOURCE_OPTIONS = MEASUREMENT_SOURCES; + +/** + * A date input's `YYYY-MM-DD` as the offset-bearing ISO datetime the list's + * `from` / `to` params need: `from` at the local start of the day, `to` at + * its local end, so a single-day range returns the whole day. + */ +export function dayBoundaryIso( + day: string, + boundary: "start" | "end", +): string | undefined { + if (!day) return undefined; + const [y, m, d] = day.split("-").map(Number); + if (!y || !m || !d) return undefined; + const date = + boundary === "start" + ? new Date(y, m - 1, d, 0, 0, 0, 0) + : new Date(y, m - 1, d, 23, 59, 59, 999); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); +} + +/** + * Per-source badge colour: Apple Health in the pink of the iOS app's accent, + * everything else the outline default. + */ +export function sourceBadgeClass(source: string): string { + if (source === "APPLE_HEALTH") { + return "border-brand-pink/50 bg-brand-pink/15 text-brand-pink"; + } + return ""; +} diff --git a/src/components/measurements/list/measurement-card-list.tsx b/src/components/measurements/list/measurement-card-list.tsx new file mode 100644 index 000000000..78da09634 --- /dev/null +++ b/src/components/measurements/list/measurement-card-list.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DayLink, DayLinkAt } from "@/components/day/day-link"; +import { formatDateOrRelative } from "@/lib/format"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; +import { ListRow } from "@/components/ui/list-row"; +import { measurementUnitMetaLead } from "@/lib/measurements/unit-label"; + +import { + MEASUREMENT_TYPE_COLORS as TYPE_COLORS, + MEASUREMENT_TYPE_ICONS as TYPE_ICONS, +} from "../measurement-list-meta"; + +import { DayDrillDown, SleepNightCaption } from "./day-drill-down"; +import { MeasurementRowActions, typeLabel } from "./row-actions"; +import { + formatMeasurementSource, + isGroupedRow, + sourceBadgeClass, + truncateComment, + type Measurement, +} from "./list-model"; +import type { MeasurementListData } from "./use-measurement-list-data"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** The phone list: one card per reading, day or night. */ +export function MeasurementCardList({ + measurements, + canManage, + filters, + list, + display, + expandedDayKeys, + toggleExpand, + navigateForType, + startEdit, +}: { + measurements: Measurement[]; + canManage: boolean; + filters: MeasurementListFilters; + list: MeasurementListData; + display: MeasurementRowDisplay; + expandedDayKeys: ReadonlySet; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { selectedIds } = filters; + const { onToggleRow, deleteMutation } = list; + return ( +
+ {measurements.map((m) => ( + deleteMutation.mutate(id)} + display={display} + toggleExpand={toggleExpand} + navigateForType={navigateForType} + startEdit={startEdit} + /> + ))} +
+ ); +} + +/** One card, and under a grouped card its open drill-down. */ +function MeasurementCard({ + m, + canManage, + isSelected, + isExpanded, + onToggleRow, + onDelete, + display, + toggleExpand, + navigateForType, + startEdit, +}: { + m: Measurement; + canManage: boolean; + isSelected: boolean; + isExpanded: boolean; + onToggleRow: (id: string) => void; + onDelete: (id: string) => void; + display: MeasurementRowDisplay; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { rowDisplay, rowIdentityAttrs } = display; + const Icon = TYPE_ICONS[m.type]; + const isGrouped = isGroupedRow(m); + const isSleep = m.type === "SLEEP_DURATION"; + const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`; + const label = typeLabel(m.type, t); + // The unit is meta: on a phone a long one ("Atemzüge/min") beside the + // value pushed the row's controls, so it leads the meta line instead. A + // night reads as a duration and has none. + const unitLead = isSleep + ? null + : measurementUnitMetaLead( + m.type, + isGrouped ? m.unit : rowDisplay(m).unit, + t, + ); + return ( + +
+
+ {!isGrouped && canManage && ( + // The checkbox owns its 44 px hit area through an `after` + // hit-slop inside a plain layout `
`: a wrapper ` + )} +
+ {/* The type names the row: without it two readings in the same + unit ("55 ms" HRV and "63 ms" RMSSD) could not be told apart + on a phone, where the table's type column does not exist. */} +

+ {label} +

+ + {isSleep + ? formatDurationMinutes(m.value, t) + : isGrouped + ? fmt.integer(m.value) + : fmt.number( + rowDisplay(m).value, + rawDisplayFractionDigits(m.type), + )} + + {isSleep ? ( + + ) : ( + isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer(m.sampleCount as number), + })} + + ) + )} + {/* The meta line wraps rather than truncates: cut short it hid + the day it opens and the source beside it. */} +

+ {unitLead ? ( + + {unitLead} + + ) : null} + {m.dayKey !== undefined ? ( + + {formatDateOrRelative(m.measuredAt, t)} + + ) : ( + + {formatDateOrRelative(m.measuredAt, t)} + + )} + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )} +

+ {m.notes && ( + // User data is never muted; the table renders the same note in + // the foreground colour. +

+ {truncateComment(m.notes)} +

+ )} +
+
+
+ +
+
+ {isGrouped && isExpanded && ( +
+ +
+ )} + + ); +} diff --git a/src/components/measurements/list/measurement-edit-sheet.tsx b/src/components/measurements/list/measurement-edit-sheet.tsx new file mode 100644 index 000000000..8a8200370 --- /dev/null +++ b/src/components/measurements/list/measurement-edit-sheet.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { createPortal } from "react-dom"; +import { Loader2, MoreHorizontal, Trash2 } from "lucide-react"; + +import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { DateTimeField } from "@/components/ui/date-time-field"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS } from "../measurement-list-meta"; + +import { MAX_COMMENT_LENGTH, toDateTimeLocalValue } from "./list-model"; +import type { MeasurementEdit } from "./use-measurement-edit"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** + * The edit sheet (a bottom sheet on phones): value in the display unit, + * timestamp, note, and the delete behind the overflow menu, with the + * actions portalled into the sheet's pinned footer. + */ +export function MeasurementEditSheet({ + edit, + display, +}: { + edit: MeasurementEdit; + display: MeasurementRowDisplay; +}) { + const { t } = useTranslations(); + const { rowDisplay } = display; + const { + editing, + closeEdit, + setEditFooterEl, + editFormId, + submitEdit, + editValue, + setEditValue, + editError, + editErrorDescriptor, + editMeasuredAt, + setEditMeasuredAt, + editNotes, + setEditNotes, + editErrorId, + editFooterEl, + deleteMutation, + setEditDeleteDialogOpen, + editDeleteDialogOpen, + deleteEditingMeasurement, + editDismissal, + } = edit; + return ( + <> + !open && closeEdit()} + title={t("measurements.editMeasurement")} + footer={
} + > + {editing && ( +
+
+ + + {TYPE_LABEL_KEYS[editing.type] + ? t(TYPE_LABEL_KEYS[editing.type]) + : editing.type} + +
+ +
+ + setEditValue(e.target.value)} + required + aria-required="true" + aria-invalid={!!editError || undefined} + aria-describedby={editErrorDescriptor} + /> +
+ +
+ + +
+ +
+
+ + + {editNotes.length}/{MAX_COMMENT_LENGTH} + +
+ setEditNotes(e.target.value)} + maxLength={MAX_COMMENT_LENGTH} + enterKeyHint="done" + autoCapitalize="sentences" + /> +
+ + {editError && ( + + )} + + {editFooterEl + ? createPortal(, editFooterEl) + : null} + + + + + + {t("measurements.deleteConfirmTitle")} + + + {t("measurements.deleteConfirmDescription")} + + + + + {t("common.cancel")} + + + {deleteMutation.isPending ? ( + + ) : null} + {t("common.delete")} + + + + + + )} + + + + ); +} + +/** + * The sheet's pinned footer: the overflow menu with delete on the left, + * cancel and save on the right. + */ +function EditSheetFooter({ edit }: { edit: MeasurementEdit }) { + const { t } = useTranslations(); + const { + updateMutation, + deleteMutation, + setEditDeleteDialogOpen, + closeEdit, + editFormId, + } = edit; + return ( +
+ + + + + + setEditDeleteDialogOpen(true)}> + + {t("common.delete")} + + + + + {/* `[Cancel] [Save]`, primary rightmost, per Apple's HIG: iOS is the + load-bearing phone target and the web shell renders desktop and the + iOS PWA from one component. Do not flip it for "Android parity"; + fork a platform shell if Android becomes a real audience. */} +
+ + +
+
+ ); +} diff --git a/src/components/measurements/list/measurement-filter-rail.tsx b/src/components/measurements/list/measurement-filter-rail.tsx new file mode 100644 index 000000000..dd417ad69 --- /dev/null +++ b/src/components/measurements/list/measurement-filter-rail.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { + FilterBar, + FilterBarDateRange, + FilterBarNumberRange, + FilterBarSelect, +} from "@/components/ui/filter-bar"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS } from "../measurement-list-meta"; + +import { + MEASUREMENT_SOURCE_OPTIONS, + formatMeasurementSource, +} from "./list-model"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; + +/** + * The filter rail: compact pill triggers in the canonical order date range · + * type · source · value range, active filters folded into their pill as + * removable chips, a reset action and the result count. + */ +export function MeasurementFilterRail({ + filters, + total, +}: { + filters: MeasurementListFilters; + total: number | undefined; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { + typeFilter, + sourceFilter, + fromDay, + toDay, + valueMinInput, + valueMaxInput, + isDayGroupedFilter, + resetFilters, + setFromDay, + setToDay, + setTypeFilter, + setSourceFilter, + setValueMinInput, + setValueMaxInput, + } = filters; + return ( + + + ({ + value: val, + label: t(labelKey), + }))} + /> + ({ + value: src, + label: formatMeasurementSource(src, t), + }))} + /> + {/* Value-range pill. Hidden for the day-grouped / + sleep-night types where the painted `value` is a synthesised + SUM / per-night total rather than a raw reading. */} + {!isDayGroupedFilter && ( + + )} + + ); +} diff --git a/src/components/measurements/list/measurement-table.tsx b/src/components/measurements/list/measurement-table.tsx new file mode 100644 index 000000000..1ba980ccd --- /dev/null +++ b/src/components/measurements/list/measurement-table.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DayLink, DayLinkAt } from "@/components/day/day-link"; +import { formatDateOrRelative } from "@/lib/format"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; +import { SortableHead } from "@/components/data-list"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { measurementUnitLabel } from "@/lib/measurements/unit-label"; + +import { MEASUREMENT_TYPE_COLORS as TYPE_COLORS } from "../measurement-list-meta"; + +import { DayDrillDown, SleepNightCaption } from "./day-drill-down"; +import { MeasurementRowActions, typeLabel } from "./row-actions"; +import { + formatMeasurementSource, + isGroupedRow, + sourceBadgeClass, + truncateComment, + type Measurement, +} from "./list-model"; +import type { MeasurementListData } from "./use-measurement-list-data"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** The desktop table: one row per reading, day or night, sortable columns. */ +export function MeasurementTable({ + measurements, + canManage, + filters, + list, + display, + expandedDayKeys, + toggleExpand, + navigateForType, + startEdit, +}: { + measurements: Measurement[]; + canManage: boolean; + filters: MeasurementListFilters; + list: MeasurementListData; + display: MeasurementRowDisplay; + expandedDayKeys: ReadonlySet; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { t } = useTranslations(); + const { sortBy, sortDir, toggleSort, selectedIds } = filters; + const { selectAll, pageIds, onToggleSelectAll, onToggleRow, deleteMutation } = + list; + return ( +
+ + + + + {/* Select all on the page. Bulk delete is the owner's alone, + so a delegate gets no selection column. */} + {canManage && ( + + )} + + {t("measurements.type")} + + + {t("measurements.comment")} + + + {t("measurements.actions")} + + + + + {measurements.map((m) => ( + deleteMutation.mutate(id)} + display={display} + toggleExpand={toggleExpand} + navigateForType={navigateForType} + startEdit={startEdit} + /> + ))} + +
+
+ ); +} + +/** One table row, and under a grouped row its open drill-down. */ +function MeasurementTableRow({ + m, + canManage, + isSelected, + isExpanded, + onToggleRow, + onDelete, + display, + toggleExpand, + navigateForType, + startEdit, +}: { + m: Measurement; + canManage: boolean; + isSelected: boolean; + isExpanded: boolean; + onToggleRow: (id: string) => void; + onDelete: (id: string) => void; + display: MeasurementRowDisplay; + toggleExpand: (dayKey: string) => void; + navigateForType: (type: string) => void; + startEdit: (m: Measurement) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { rowDisplay, rowIdentityAttrs } = display; + const isGrouped = isGroupedRow(m); + // A sleep night is grouped (its chevron opens the stage segments) but + // reads as a duration with a nap caption, not as a daily total. + const isSleep = m.type === "SLEEP_DURATION"; + // A stable id, so the chevron's `aria-controls` names the panel it opens. + const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`; + const label = typeLabel(m.type, t); + return ( + <> + + + {/* A grouped row is not individually deletable: no checkbox. */} + {!isGrouped && canManage && ( + onToggleRow(m.id)} + aria-label={t("dataList.selectRow")} + /> + )} + + + + + + + + {/* A reading keeps its native precision (78.4 kg stays 78.4); a + day total of a cumulative type is a whole number; a night + reads as a duration ("8h 12m") with its nap caption. */} + {isSleep ? ( + <> + {formatDurationMinutes(m.value, t)} + + + ) : ( + <> + {isGrouped + ? fmt.integer(m.value) + : fmt.number( + rowDisplay(m).value, + rawDisplayFractionDigits(m.type), + )}{" "} + {measurementUnitLabel( + m.type, + isGrouped ? m.unit : rowDisplay(m).unit, + t, + )} + {isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer(m.sampleCount as number), + })} + + )} + + )} + + {/* Relative inside the last 24 hours ("3 min ago"), so the list and + the dashboard never phrase "when" differently. The date opens its + day; the row keeps opening the reading. */} + + {m.dayKey !== undefined ? ( + + {formatDateOrRelative(m.measuredAt, t)} + + ) : ( + + {formatDateOrRelative(m.measuredAt, t)} + + )} + + + {m.notes ? ( + {truncateComment(m.notes)} + ) : ( + - + )} + + + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )} + + +
+ +
+
+
+ {isGrouped && isExpanded && ( + + + + + + )} + + ); +} diff --git a/src/components/measurements/list/row-actions.tsx b/src/components/measurements/list/row-actions.tsx new file mode 100644 index 000000000..143d65562 --- /dev/null +++ b/src/components/measurements/list/row-actions.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { ChevronDown, Pencil } from "lucide-react"; + +import { DeleteButton } from "@/components/data-list"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS } from "../measurement-list-meta"; + +import type { Measurement } from "./list-model"; + +type Translate = ReturnType["t"]; + +/** A type's name, or the raw type for one the catalogue does not know. */ +export function typeLabel(type: string, t: Translate): string { + return TYPE_LABEL_KEYS[type] ? t(TYPE_LABEL_KEYS[type]) : type; +} + +/** + * A row's controls: the chevron into a day's or night's samples for a + * grouped row; edit (owner only) and delete for a reading. The table's + * buttons are 40 px, the phone list's the large icon size. + */ +export function MeasurementRowActions({ + m, + isGrouped, + isExpanded, + drilldownId, + canManage, + layout, + toggleExpand, + startEdit, + onDelete, +}: { + m: Measurement; + isGrouped: boolean; + isExpanded: boolean; + drilldownId: string; + canManage: boolean; + layout: "desktop" | "mobile"; + toggleExpand: (dayKey: string) => void; + startEdit: (m: Measurement) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslations(); + const desktop = layout === "desktop"; + const size = desktop ? "icon" : "icon-lg"; + const className = desktop ? "h-10 w-10" : undefined; + return isGrouped ? ( + + ) : ( + <> + {canManage && ( + + )} + onDelete(m.id)} + title={t("measurements.deleteConfirmTitle")} + description={t("measurements.deleteConfirmDescription")} + /> + + ); +} diff --git a/src/components/measurements/list/use-measurement-edit.ts b/src/components/measurements/list/use-measurement-edit.ts new file mode 100644 index 000000000..e7ae637e7 --- /dev/null +++ b/src/components/measurements/list/use-measurement-edit.ts @@ -0,0 +1,268 @@ +"use client"; + +import { useId, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { useSeededFormDismissal } from "@/components/forms/use-seeded-form-dismissal"; +import { ApiError, apiPut } from "@/lib/api/api-fetch"; +import { toCanonicalMgdl } from "@/lib/glucose"; +import { useTranslations } from "@/lib/i18n/context"; +import { getUnitForType } from "@/lib/measurements/unit-map"; +import { + invalidateKeys, + measurementDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; + +import { + MAX_COMMENT_LENGTH, + toDateTimeLocalValue, + type Measurement, +} from "./list-model"; +import type { MeasurementListData } from "./use-measurement-list-data"; +import type { MeasurementRowDisplay } from "./use-measurement-row-display"; + +/** + * The edit input's seed, in the preferred unit, so an imperial reader edits + * in lb / in / °F (converted back on save), rounded so the input shows "210" + * rather than a long float. Metric readers and legacy rows see the stored + * value. + */ +export function editSeedValue( + { + unitDisplay, + editsConvertedGlucose, + rowDisplay, + }: Pick< + MeasurementRowDisplay, + "unitDisplay" | "editsConvertedGlucose" | "rowDisplay" + >, + measurement: Measurement, +): number { + const rd = rowDisplay(measurement); + return editsConvertedGlucose(measurement) + ? rd.value + : unitDisplay.preference === "imperial" && + unitDisplay.isTransformed(measurement.type) && + measurement.unit === getUnitForType(measurement.type) + ? Math.round(rd.value * 100) / 100 + : measurement.value; +} + +/** + * The input's display-unit value inverted to the stored unit before the + * request. Metric readers and legacy rows pass unchanged. + */ +export function storedValueFor( + { + unitDisplay, + editsConvertedGlucose, + }: Pick, + editing: Measurement, + parsedValue: number, +): number { + if (editsConvertedGlucose(editing)) { + // The label said mmol/L, so the number is mmol/L. Sending it on would + // rewrite a 95 mg/dL reading as 5.3. + return toCanonicalMgdl(parsedValue, "mmol/L"); + } + if ( + unitDisplay.isTransformed(editing.type) && + editing.unit === getUnitForType(editing.type) + ) { + const inverted = unitDisplay.fromDisplay(editing.type, parsedValue); + return unitDisplay.preference === "imperial" + ? Math.round(inverted * 100) / 100 + : inverted; + } + return parsedValue; +} + +/** + * The edit sheet's state: the reading being edited, the draft seeded in the + * person's display unit, the save (converted back to the stored unit) and + * the delete from inside the sheet, and the dismissal guard that asks before + * an edited draft is thrown away. + */ +export function useMeasurementEdit({ + onEdit, + display, + deleteMutation, +}: { + onEdit?: (m: Measurement) => void; + display: MeasurementRowDisplay; + deleteMutation: MeasurementListData["deleteMutation"]; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(null); + const [editValue, setEditValue] = useState(""); + const [editMeasuredAt, setEditMeasuredAt] = useState(""); + const [editNotes, setEditNotes] = useState(""); + const [editSeed, setEditSeed] = useState({ + value: "", + measuredAt: "", + notes: "", + }); + const [editError, setEditError] = useState(null); + const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); + // The error banner is linked to the inputs through `aria-describedby`, so a + // screen reader announces why a submit was refused. + const editErrorId = useId(); + // The sheet's footer is a slot the form portals its buttons into. + const editFormId = useId(); + const [editFooterEl, setEditFooterEl] = useState(null); + + const updateMutation = useMutation({ + mutationFn: async ({ + id, + value, + measuredAt, + notes, + }: { + id: string; + value: number; + measuredAt: string; + notes: string | null; + }) => { + await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes }); + }, + onSuccess: async () => { + await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); + setEditSeed({ + value: editValue, + measuredAt: editMeasuredAt, + notes: editNotes, + }); + dismissEdit(); + }, + onError: (err) => { + // A 409 names its reason in `meta.errorCode`: the edit collides with + // another reading's (type, time, source, stage), or the reading belongs + // to a server-owned source (connector, import, computed). + if ( + err instanceof ApiError && + err.meta?.errorCode === "measurement.duplicate_timestamp" + ) { + setEditError(t("measurements.duplicateTimestamp")); + return; + } + if ( + err instanceof ApiError && + err.meta?.errorCode === "measurement.update.server_owned_source" + ) { + setEditError(t("measurements.serverOwnedSource")); + return; + } + setEditError( + err instanceof Error ? err.message : t("measurements.saveError"), + ); + }, + }); + const editDismissal = useSeededFormDismissal({ + seed: editSeed, + value: { + value: editValue, + measuredAt: editMeasuredAt, + notes: editNotes, + }, + blocked: updateMutation.isPending || deleteMutation.isPending, + }); + + function startEdit(measurement: Measurement) { + if (onEdit) { + onEdit(measurement); + return; + } + + const seedValue = editSeedValue(display, measurement); + const seed = { + value: String(seedValue), + measuredAt: toDateTimeLocalValue(measurement.measuredAt), + notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH), + }; + setEditing(measurement); + setEditValue(seed.value); + setEditMeasuredAt(seed.measuredAt); + setEditNotes(seed.notes); + setEditSeed(seed); + setEditError(null); + } + + function dismissEdit() { + setEditing(null); + setEditError(null); + setEditDeleteDialogOpen(false); + } + + function closeEdit() { + editDismissal.requestClose(dismissEdit); + } + + async function deleteEditingMeasurement() { + if (!editing) return; + + try { + setEditError(null); + await deleteMutation.mutateAsync(editing.id); + dismissEdit(); + } catch { + setEditError(t("measurements.deleteError")); + } + } + + async function submitEdit(e: React.FormEvent) { + e.preventDefault(); + if (!editing) return; + + const parsedValue = Number(editValue); + if (!Number.isFinite(parsedValue)) { + setEditError(t("measurements.invalidValue")); + return; + } + + const measuredDate = new Date(editMeasuredAt); + if (Number.isNaN(measuredDate.getTime())) { + setEditError(t("measurements.invalidTimestamp")); + return; + } + + const canonicalValue = storedValueFor(display, editing, parsedValue); + + setEditError(null); + updateMutation.mutate({ + id: editing.id, + value: canonicalValue, + measuredAt: measuredDate.toISOString(), + notes: editNotes.trim() ? editNotes.trim() : null, + }); + } + + return { + editing, + editValue, + setEditValue, + editMeasuredAt, + setEditMeasuredAt, + editNotes, + setEditNotes, + editError, + editErrorId, + editErrorDescriptor: editError ? editErrorId : undefined, + editFormId, + editFooterEl, + setEditFooterEl, + editDeleteDialogOpen, + setEditDeleteDialogOpen, + editDismissal, + updateMutation, + deleteMutation, + startEdit, + closeEdit, + deleteEditingMeasurement, + submitEdit, + }; +} + +export type MeasurementEdit = ReturnType; diff --git a/src/components/measurements/list/use-measurement-list-data.ts b/src/components/measurements/list/use-measurement-list-data.ts new file mode 100644 index 000000000..81c2db1e9 --- /dev/null +++ b/src/components/measurements/list/use-measurement-list-data.ts @@ -0,0 +1,219 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { + selectAllState, + selectedCountOnPage, + selectedIdsOnPage, + toggleId, + toggleSelectAll, +} from "@/components/data-list"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; + +import { PAGE_SIZE, isGroupedRow, type Measurement } from "./list-model"; +import type { MeasurementListFilters } from "./use-measurement-list-filters"; + +/** + * The list's read and its deletes. Deletes are soft (tombstones), so each + * success toast carries a real Undo that restores the ids and re-fires the + * same dependent-key bundle, keeping the dashboard, insights and chart + * caches in step. + */ +export function useMeasurementListData({ + filters, + enabled, + lockedType, + onEmptyChange, +}: { + filters: MeasurementListFilters; + enabled: boolean; + lockedType: string | undefined; + onEmptyChange?: (empty: boolean) => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { + typeFilter, + sourceEq, + fromIso, + toIso, + valueMin, + valueMax, + isDayGroupedFilter, + isCumulativeFilter, + page, + sortBy, + sortDir, + listMode, + selectedIds, + setSelectedIds, + clearSelection, + } = filters; + + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: queryKeys.measurementsList({ + type: typeFilter === "ALL" ? undefined : typeFilter, + sourceEq, + from: fromIso, + to: toIso, + // Value range applies only to the raw per-sample list. + valueMin: isDayGroupedFilter ? undefined : valueMin, + valueMax: isDayGroupedFilter ? undefined : valueMax, + page, + sortBy, + sortDir, + mode: listMode, + }), + queryFn: async () => { + const params = new URLSearchParams(); + if (typeFilter !== "ALL") params.set("type", typeFilter); + if (sourceEq) params.set("sourceEq", sourceEq); + if (fromIso) params.set("from", fromIso); + if (toIso) params.set("to", toIso); + if (!isDayGroupedFilter && valueMin != null) + params.set("valueMin", String(valueMin)); + if (!isDayGroupedFilter && valueMax != null) + params.set("valueMax", String(valueMax)); + params.set("limit", String(PAGE_SIZE)); + params.set("offset", String((page - 1) * PAGE_SIZE)); + params.set("sortBy", sortBy); + params.set("sortDir", sortDir); + // The day-grouped modes page over days, 25 per page like the + // per-sample list: `groupBy=day` folds each cumulative day to its sum, + // and sleep collapses to one row per night without a param. + if (isCumulativeFilter) params.set("groupBy", "day"); + return apiGet<{ + measurements: Measurement[]; + meta: { total: number }; + }>(`/api/measurements?${params}`); + }, + enabled, + }); + + const listEmpty = + !isLoading && + !isError && + !data?.measurements?.length && + (typeFilter === "ALL" || !!lockedType); + useEffect(() => { + onEmptyChange?.(listEmpty); + }, [listEmpty, onEmptyChange]); + + const restoreMeasurements = useCallback( + async (ids: string[]) => { + try { + await apiPost("/api/measurements/restore", { ids }); + await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); + toast.success(t("measurements.restoredToast")); + } catch (failure) { + showErrorToast(t("measurements.restoreError"), { cause: failure }); + } + }, + [queryClient, t], + ); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await apiDelete(`/api/measurements/${id}`); + }, + onSuccess: (_data, id) => { + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); + toast.success(t("measurements.deletedToast"), { + action: { + label: t("common.undo"), + onClick: () => void restoreMeasurements([id]), + }, + }); + }, + // A failed row delete says so; it used to fail without a word. + onError: (failure) => { + showErrorToast(t("measurements.deleteError"), { cause: failure }); + }, + }); + + // The page-scoped bulk delete: the selected ids intersected with the + // painted page. + const bulkDeleteMutation = useMutation({ + mutationFn: async (ids: string[]) => { + const data = await apiPost<{ deleted: number }>( + "/api/measurements/bulk-delete", + { ids }, + ); + return data.deleted; + }, + onSuccess: async (deleted, ids) => { + await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); + clearSelection(); + toast.success( + deleted === 1 + ? t("measurements.bulkDeleteSuccessOne") + : t("measurements.bulkDeleteSuccess", { count: String(deleted) }), + { + action: { + label: t("common.undo"), + onClick: () => void restoreMeasurements(ids), + }, + }, + ); + }, + onError: (failure) => { + showErrorToast(t("measurements.bulkDeleteError"), { cause: failure }); + }, + }); + + // Synthetic day and night rows collapse many raw rows behind a `dayKey` + // and are not individually deletable, so they are not selectable. + const pageIds = (data?.measurements ?? []) + .filter((m) => !isGroupedRow(m)) + .map((m) => m.id); + const selectAll = selectAllState(selectedIds, pageIds); + const selectedOnPage = selectedCountOnPage(selectedIds, pageIds); + + const onToggleRow = (id: string) => { + setSelectedIds((prev) => toggleId(prev, id)); + }; + const onToggleSelectAll = () => { + setSelectedIds((prev) => toggleSelectAll(prev, pageIds)); + }; + const onConfirmBulkDelete = () => { + const ids = selectedIdsOnPage(selectedIds, pageIds); + if (ids.length === 0) return; + bulkDeleteMutation.mutate(ids); + }; + + // Every mode pages server-side at PAGE_SIZE: rows, days or nights. + const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0; + + return { + data, + isLoading, + isError, + error, + refetch, + deleteMutation, + bulkDeleteMutation, + pageIds, + selectAll, + selectedOnPage, + onToggleRow, + onToggleSelectAll, + onConfirmBulkDelete, + totalPages, + }; +} + +export type MeasurementListData = ReturnType; diff --git a/src/components/measurements/list/use-measurement-list-filters.ts b/src/components/measurements/list/use-measurement-list-filters.ts new file mode 100644 index 000000000..b77ed5ab9 --- /dev/null +++ b/src/components/measurements/list/use-measurement-list-filters.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { useTableSort } from "@/hooks/use-table-sort"; +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; + +import { + measurementListFiltersToSearch, + numericInputOrUndefined, + parseMeasurementListSearchParams, +} from "../measurement-list-filters"; + +import { + CUMULATIVE_TYPES, + MEASUREMENT_DESC_COLUMNS, + dayBoundaryIso, +} from "./list-model"; + +/** + * The list's view state: the filters, the page, the sort and the page-scoped + * selection, and what the read derives from them. + * + * Filters are URL-owned (`?type&source&from&to&min&max`, the documents-vault + * pattern), so a filtered view survives navigation, reload and back/forward, + * and the `?type=` deep links simply are the filter state. With `lockedType` + * the list is pinned to one metric, the URL belongs to the page around it, + * and the remaining filters stay in memory. + * + * Every filter, page or sort change resets to page 1 and clears the + * selection, whose rows are about to unmount; the selection never reaches + * past the painted page. + */ +export function useMeasurementListFilters(lockedType: string | undefined) { + const { filters, applyFilters, patchFilters } = useUrlFilterSync({ + parse: parseMeasurementListSearchParams, + serialise: measurementListFiltersToSearch, + syncToUrl: lockedType === undefined, + }); + const typeFilter = lockedType ?? filters.type ?? "ALL"; + // `ALL` clears the source filter; an empty date string clears its bound. + const sourceFilter = filters.source ?? "ALL"; + const fromDay = filters.fromDay ?? ""; + const toDay = filters.toDay ?? ""; + // The value-range inputs fire per keystroke, so they edit a local draft + // that debounces into the URL (200 ms, replace: never a history entry per + // keystroke); an external URL change (back button, deep link) re-seeds the + // draft through the render-phase adjustment below, like the vault's search. + const urlValueRange = `${filters.valueMin ?? ""}\u0000${filters.valueMax ?? ""}`; + const [valueDraft, setValueDraft] = useState(() => ({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + })); + const [lastUrlValueRange, setLastUrlValueRange] = useState(urlValueRange); + if (urlValueRange !== lastUrlValueRange) { + setLastUrlValueRange(urlValueRange); + setValueDraft({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + }); + } + const [page, setPage] = useState(1); + // Shared column-sort state (measuredAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "measuredAt", + defaultDir: "desc", + descColumns: MEASUREMENT_DESC_COLUMNS, + }); + + // The ids selected on the CURRENT page. Every read intersects it with the + // painted page, so the bulk payload is page-bounded (at most a page, well + // under the server's cap of 200) and an id that falls off the page after + // a refetch simply stops counting. + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const clearSelection = () => setSelectedIds(new Set()); + + // Discrete facet changes push (Back steps through filter states, like the + // vault's chips); the committed date bounds replace, so a from/to pair set + // in one sitting does not cost two Back presses. + const resetPage = () => { + setPage(1); + clearSelection(); + }; + const setTypeFilter = (value: string) => { + patchFilters({ type: value === "ALL" ? undefined : value }); + resetPage(); + }; + const setSourceFilter = (value: string) => { + patchFilters({ source: value === "ALL" ? undefined : value }); + resetPage(); + }; + const setFromDay = (value: string) => { + patchFilters({ fromDay: value || undefined }, "replace"); + resetPage(); + }; + const setToDay = (value: string) => { + patchFilters({ toDay: value || undefined }, "replace"); + resetPage(); + }; + const setValueMinInput = (value: string) => { + setValueDraft((prev) => ({ ...prev, min: value })); + resetPage(); + }; + const setValueMaxInput = (value: string) => { + setValueDraft((prev) => ({ ...prev, max: value })); + resetPage(); + }; + // One push clears every facet (and so one Back restores them); the value + // draft is cleared directly, since a bound that never reached the URL has + // no change to re-seed from. + const resetFilters = () => { + applyFilters({}); + setValueDraft({ min: "", max: "" }); + resetPage(); + }; + + // The draft debounces into the URL; a bound that does not normalise to a + // finite number never reaches it (non-numeric input means "unset"). + useEffect(() => { + const handle = setTimeout(() => { + const min = numericInputOrUndefined(valueDraft.min); + const max = numericInputOrUndefined(valueDraft.max); + if ( + (min ?? "") === (filters.valueMin ?? "") && + (max ?? "") === (filters.valueMax ?? "") + ) { + return; + } + patchFilters({ valueMin: min, valueMax: max }, "replace"); + }, 200); + return () => clearTimeout(handle); + }, [valueDraft, filters, patchFilters]); + + const goToPage = (updater: (p: number) => number) => { + setPage(updater); + clearSelection(); + }; + + // The rows re-order and the list snaps back to page 1. + function toggleSort(column: string) { + applySort(column); + resetPage(); + } + + // A cumulative type (steps, active energy, distance, flights, daylight) + // collapses to one row per profile-zone day through the route's + // `groupBy=day` mode, never listing per-sample chunks by default; sleep + // collapses server-side to one row per night (time asleep). Both page + // server-side over their synthesised rows. + const isCumulativeFilter = CUMULATIVE_TYPES.has(typeFilter); + const isSleepFilter = typeFilter === "SLEEP_DURATION"; + const isDayGroupedFilter = isCumulativeFilter || isSleepFilter; + + const fromIso = dayBoundaryIso(fromDay, "start"); + const toIso = dayBoundaryIso(toDay, "end"); + const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter; + // The value range applies to the raw per-sample list only: a day sum or a + // night total is not a raw `value`, so its pill is hidden for those types. + const valueMin = + filters.valueMin !== undefined ? Number(filters.valueMin) : undefined; + const valueMax = + filters.valueMax !== undefined ? Number(filters.valueMax) : undefined; + const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter + ? "sleep-night" + : isCumulativeFilter + ? "groupBy=day" + : "raw"; + + return { + typeFilter, + sourceFilter, + fromDay, + toDay, + valueMinInput: valueDraft.min, + valueMaxInput: valueDraft.max, + page, + sortBy, + sortDir, + selectedIds, + setSelectedIds, + clearSelection, + setTypeFilter, + setSourceFilter, + setFromDay, + setToDay, + setValueMinInput, + setValueMaxInput, + resetFilters, + goToPage, + toggleSort, + isCumulativeFilter, + isDayGroupedFilter, + fromIso, + toIso, + sourceEq, + valueMin, + valueMax, + listMode, + }; +} + +export type MeasurementListFilters = ReturnType< + typeof useMeasurementListFilters +>; diff --git a/src/components/measurements/list/use-measurement-row-display.ts b/src/components/measurements/list/use-measurement-row-display.ts new file mode 100644 index 000000000..042a6ee8f --- /dev/null +++ b/src/components/measurements/list/use-measurement-row-display.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useCallback, useMemo } from "react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; +import { + convertGlucose, + resolveGlucoseUnit, + type GlucoseUnit, +} from "@/lib/glucose"; +import { getUnitForType } from "@/lib/measurements/unit-map"; + +import type { Measurement } from "./list-model"; + +/** + * How a row shows its reading: in the person's preferred unit (kg ↔ lb, + * cm ↔ in, °C ↔ °F) for a canonical row, verbatim for a legacy row whose + * stored unit is not the type's canonical one (so a stored unit is never + * silently relabelled). Glucose sits on its own axis, stored in mg/dL and + * shown in whichever of mg/dL and mmol/L the account chose, so the list and + * the dashboard tile agree about the same reading. + * + * `rowDisplayFor` is that rule for one glucose unit and one metric / + * imperial preference; the hook binds it to the account. + */ +export function rowDisplayFor( + glucoseUnit: GlucoseUnit, + unitDisplay: UnitDisplay, +) { + // An edit whose input is NOT in the stored unit. Only the mmol/L branch + // qualifies: an mg/dL account edits in the unit the column holds, and + // converting anyway would quantise a stored 95.48 to 95 the moment somebody + // opened the sheet to fix a note. + const editsConvertedGlucose = (m: { type: string; unit: string }) => + m.type === "BLOOD_GLUCOSE" && + glucoseUnit === "mmol/L" && + m.unit === getUnitForType(m.type); + const rowDisplay = (m: { type: string; value: number; unit: string }) => { + if (m.unit !== getUnitForType(m.type)) { + return { value: m.value, unit: m.unit }; + } + if (m.type === "BLOOD_GLUCOSE") { + return { + value: convertGlucose(m.value, glucoseUnit), + unit: glucoseUnit, + }; + } + return { + value: unitDisplay.toDisplay(m.type, m.value), + unit: unitDisplay.unitFor(m.type), + }; + }; + return { unitDisplay, editsConvertedGlucose, rowDisplay }; +} + +export function useMeasurementRowDisplay() { + const { user } = useAuth(); + const unitDisplay = useUnitDisplay(); + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); + const { editsConvertedGlucose, rowDisplay } = useMemo( + () => rowDisplayFor(glucoseUnit, unitDisplay), + [glucoseUnit, unitDisplay], + ); + /** + * A machine-readable mirror of what the row renders: the same number and + * unit, before `Intl` turns them into locale text. A browser test that has + * to assert a stored unit would otherwise pin a locale or match a loose + * number. The desktop table and the mobile list emit the same set, so an + * assertion holds at either viewport. + */ + const rowIdentityAttrs = useCallback( + (m: Measurement, isGrouped: boolean) => ({ + "data-testid": "measurement-row", + "data-measurement-type": m.type, + "data-measurement-value": String( + isGrouped ? m.value : rowDisplay(m).value, + ), + "data-measurement-unit": isGrouped ? m.unit : rowDisplay(m).unit, + }), + [rowDisplay], + ); + return { unitDisplay, editsConvertedGlucose, rowDisplay, rowIdentityAttrs }; +} + +export type MeasurementRowDisplay = ReturnType; diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 6ebe3eeec..ac65e359e 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1,198 +1,38 @@ "use client"; -import { accountLabel } from "@/lib/sharing/account-access-view"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useIsMobile } from "@/hooks/use-is-mobile"; +import { Activity, ChevronLeft, ChevronRight, Plus } from "lucide-react"; + +import { SelectionActionBar } from "@/components/data-list"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; -import { ListRow } from "@/components/ui/list-row"; -import { - FilterBar, - FilterBarDateRange, - FilterBarNumberRange, - FilterBarSelect, -} from "@/components/ui/filter-bar"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { - SeededFormDiscardDialog, - useSeededFormDismissal, -} from "@/components/forms/use-seeded-form-dismissal"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Activity, - Loader2, - Pencil, - Plus, - Trash2, - ChevronLeft, - ChevronRight, - ChevronDown, - MoreHorizontal, -} from "lucide-react"; -import { Fragment, useCallback, useEffect, useId, useState } from "react"; -import { useTableSort } from "@/hooks/use-table-sort"; -import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; -import { createPortal } from "react-dom"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/errors/error-toast"; -import { formatDateOrRelative, formatDateTime } from "@/lib/format"; -import { DayLink, DayLinkAt } from "@/components/day/day-link"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; -import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; -import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; -import { getUnitForType } from "@/lib/measurements/unit-map"; -import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { - convertGlucose, - resolveGlucoseUnit, - toCanonicalMgdl, -} from "@/lib/glucose"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, - refetchInactiveDailyReads, -} from "@/lib/query-keys"; -import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit"; -import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources"; -import { DateTimeField } from "@/components/ui/date-time-field"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - SortableHead, - DeleteButton, - SelectionActionBar, - toggleId, - toggleSelectAll, - selectAllState, - selectedIdsOnPage, - selectedCountOnPage, -} from "@/components/data-list"; -import { - MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS, - MEASUREMENT_TYPE_ICONS as TYPE_ICONS, - MEASUREMENT_TYPE_COLORS as TYPE_COLORS, -} from "./measurement-list-meta"; -import { - measurementUnitLabel, - measurementUnitMetaLead, -} from "@/lib/measurements/unit-label"; -import { - measurementListFiltersToSearch, - numericInputOrUndefined, - parseMeasurementListSearchParams, -} from "./measurement-list-filters"; -import { - ApiError, - apiDelete, - apiGet, - apiPost, - apiPut, -} from "@/lib/api/api-fetch"; +import { useAuth } from "@/hooks/use-auth"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { INSIGHTS_OVERVIEW_PATH, subPageSlugForType, } from "@/lib/insights/sub-page-metric"; -import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { accountLabel } from "@/lib/sharing/account-access-view"; -/** - * v1.4.37 — cumulative HK types whose list view collapses to one - * row per user-TZ day with an expand-chevron drill-down to the - * per-sample chunks. Mirrors the server-side - * `CUMULATIVE_DAY_SUM_TYPES` so the route's `groupBy=day` branch and - * the client's filter-detection agree on the same five identifiers. - */ -const CUMULATIVE_TYPES = new Set(CUMULATIVE_DAY_SUM_TYPES); - -// Columns that open descending when first selected. The date column reads -// newest-first by default; every other column opens ascending. -const MEASUREMENT_DESC_COLUMNS: ReadonlySet = new Set(["measuredAt"]); - -interface Measurement { - id: string; - type: string; - value: number; - unit: string; - source: string; - measuredAt: string; - notes: string | null; - /** - * v1.4.37 — present only on collapsed daily rows returned by - * `?type=…&groupBy=day`. Drives the expand chevron + the drill-down - * fetch keyed by the user's calendar day. - */ - dayKey?: string; - sampleCount?: number; - /** - * v1.11.5 — present only on per-night SLEEP_DURATION rows. `value` is - * the night's TIME ASLEEP in minutes; these fields carry the night's - * context so the row reads as one night, not many stage values. - */ - sleepStage?: string | null; - napCount?: number; - napAsleepMinutes?: number; - awakenings?: number; -} - -/** v1.11.5 — i18n labels for the sleep stages shown in the drill-down. */ -function sleepStageLabel( - stage: string | null | undefined, - t: ReturnType["t"], -): string { - switch (stage) { - case "DEEP": - return t("insights.sleep.stages.deep"); - case "REM": - return t("insights.sleep.stages.rem"); - case "CORE": - return t("insights.sleep.stages.core"); - case "ASLEEP": - return t("insights.sleep.stages.asleep"); - case "AWAKE": - return t("insights.sleep.stages.awake"); - case "IN_BED": - return t("insights.sleep.stages.inBed"); - default: - return stage ?? ""; - } -} +import { MeasurementCardList } from "./list/measurement-card-list"; +import { MeasurementEditSheet } from "./list/measurement-edit-sheet"; +import { MeasurementFilterRail } from "./list/measurement-filter-rail"; +import { MeasurementTable } from "./list/measurement-table"; +import type { Measurement } from "./list/list-model"; +import { useMeasurementEdit } from "./list/use-measurement-edit"; +import { useMeasurementListData } from "./list/use-measurement-list-data"; +import { useMeasurementListFilters } from "./list/use-measurement-list-filters"; +import { useMeasurementRowDisplay } from "./list/use-measurement-row-display"; interface MeasurementListProps { onEdit?: (m: Measurement) => void; /** - * v1.4.15: optional callback wired by the parent page so + * Optional callback wired by the parent page so * the empty-state's "Add your first measurement" CTA opens the same * dialog the header button does. When undefined we hide the button * (fallback to the header CTA) instead of crashing. @@ -204,7 +44,7 @@ interface MeasurementListProps { */ onEmptyChange?: (empty: boolean) => void; /** - * v1.8.5 — pin the list to a single `MeasurementType` and hide the + * Pin the list to a single `MeasurementType` and hide the * type selector. Used by the insights "all readings" subpage, which * already knows the metric from the route and wants a focused list * (pagination + inline edit/delete) rather than the global filter UI. @@ -214,83 +54,12 @@ interface MeasurementListProps { lockedType?: string; } -const PAGE_SIZE = 25; -// Input cap mirrors the server Zod bound. The list preview truncates at -// a shorter width so a long note does not stretch a list row. -const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH; -const COMMENT_PREVIEW_LENGTH = 40; - -function truncateComment(comment: string): string { - if (comment.length <= COMMENT_PREVIEW_LENGTH) return comment; - return `${comment.slice(0, COMMENT_PREVIEW_LENGTH - 1)}…`; -} - -function toDateTimeLocalValue(isoString: string): string { - const date = new Date(isoString); - const offset = date.getTimezoneOffset(); - const local = new Date(date.getTime() - offset * 60 * 1000); - return local.toISOString().slice(0, 16); -} - /** - * Render the `MeasurementSource` enum using the `measurements.source*` - * translation keys instead of leaking the SCREAMING_SNAKE enum into the - * table cell or the filter dropdown. The catalogue is keyed by the enum, - * so a provider added without its label is a type error rather than a row - * that reads `GOOGLE_HEALTH`. + * Every reading, filterable, sortable and paged, with inline edit and + * delete. The view state, the read, the edit and the row display live in + * `./list/`; this component lays out the rail, the rows, the selection bar, + * the pagination and the edit sheet. */ -function formatMeasurementSource( - source: string, - t: ReturnType["t"], -) { - const key = MEASUREMENT_SOURCE_LIST_LABEL_KEYS[ - source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS - ] as string | undefined; - // A row written by a server the client does not know yet still renders - // as its own value rather than as a missing translation key. - return key ? t(key) : source; -} - -/** - * v1.15.13 — the `MeasurementSource` enum values offered in the - * management-list source filter. Derived from the shared Zod enum so a - * future source addition surfaces here automatically. - */ -const MEASUREMENT_SOURCE_OPTIONS = MEASUREMENT_SOURCES; - -/** - * v1.15.13 — convert a `` value (YYYY-MM-DD) into the - * offset-bearing ISO datetime the measurements list `from`/`to` params - * require. `from` snaps to the local start-of-day, `to` to the local - * end-of-day so an inclusive single-day range returns that whole day. - */ -function dayBoundaryIso( - day: string, - boundary: "start" | "end", -): string | undefined { - if (!day) return undefined; - const [y, m, d] = day.split("-").map(Number); - if (!y || !m || !d) return undefined; - const date = - boundary === "start" - ? new Date(y, m - 1, d, 0, 0, 0, 0) - : new Date(y, m - 1, d, 23, 59, 59, 999); - if (Number.isNaN(date.getTime())) return undefined; - return date.toISOString(); -} - -/** - * Per-source badge colour. `APPLE_HEALTH` gets the Dracula pink that - * matches the iOS app's accent; everything else falls back to the - * shadcn outline default (no class override). - */ -function sourceBadgeClass(source: string): string { - if (source === "APPLE_HEALTH") { - return "border-brand-pink/50 bg-brand-pink/15 text-brand-pink"; - } - return ""; -} - export function MeasurementList({ onEdit, onAddFirst, @@ -300,9 +69,9 @@ export function MeasurementList({ const { t } = useTranslations(); const fmt = useFormatters(); const { isAuthenticated, user } = useAuth(); - // v1.36.x — inside somebody else's record, only the add path survives: - // entering a reading is an admitted delegated write, editing and deleting - // one are not. The row controls are dropped rather than disabled. + // Inside somebody else's record only the add path survives: entering a + // reading is an admitted delegated write, editing and deleting one are not. + // The row controls are dropped rather than disabled. const { canWriteDomain, canManage, inSharedRecord, canWrite } = useRecordCapabilities(); const canAddMeasurement = canWriteDomain("measurements"); @@ -314,87 +83,16 @@ export function MeasurementList({ inSharedRecord && canWrite && activeRecord ? accountLabel(activeRecord) : null; - const unitDisplay = useUnitDisplay(); - // v1.32.26 — a single-reading row renders its value + unit in the user's - // preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the - // transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the - // type's canonical unit) is shown verbatim so its stored unit is never - // silently relabelled. Metric users take the identity path — unchanged. - // Glucose sits on its own display axis: stored mg/dL, rendered in whichever - // of mg/dL and mmol/L the account chose. It is not part of the - // metric/imperial registry, so it needs its own branch or a reader on - // mmol/L would see the dashboard tile and this list disagree about the - // same reading. - const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); - // An edit whose input is NOT in the stored unit. Only the mmol/L branch - // qualifies: an mg/dL account edits in the unit the column holds, and - // routing it through the converter anyway would quantise a stored 95.48 to - // 95 the moment somebody opened the sheet to fix a note. - const editsConvertedGlucose = useCallback( - (m: { type: string; unit: string }) => - m.type === "BLOOD_GLUCOSE" && - glucoseUnit === "mmol/L" && - m.unit === getUnitForType(m.type), - [glucoseUnit], - ); - const rowDisplay = useCallback( - (m: { type: string; value: number; unit: string }) => { - if (m.unit !== getUnitForType(m.type)) { - return { value: m.value, unit: m.unit }; - } - if (m.type === "BLOOD_GLUCOSE") { - return { - value: convertGlucose(m.value, glucoseUnit), - unit: glucoseUnit, - }; - } - return { - value: unitDisplay.toDisplay(m.type, m.value), - unit: unitDisplay.unitFor(m.type), - }; - }, - [unitDisplay, glucoseUnit], - ); - /** - * Machine-readable mirror of what the row renders — the same number and - * the same unit, before `Intl` turns them into locale text. - * - * The rendered cell is the only place a reading's value and unit appear - * together, and it appears there as "8.421 steps" or "8,421 steps" - * depending on who is looking. A browser test that has to assert a stored - * unit therefore either pins a locale or matches a loose number, and both - * describe the formatter rather than the reading. These attributes carry - * the reading itself, and the desktop table and the mobile list emit the - * same set, so an assertion holds at either viewport. - */ - const rowIdentityAttrs = useCallback( - (m: Measurement, isGrouped: boolean) => ({ - "data-testid": "measurement-row", - "data-measurement-type": m.type, - "data-measurement-value": String( - isGrouped ? m.value : rowDisplay(m).value, - ), - "data-measurement-unit": isGrouped ? m.unit : rowDisplay(m).unit, - }), - [rowDisplay], - ); - // v1.28.42 — the desktop table and the mobile card list used to BOTH - // render (only CSS `display` hid one), so a dense cumulative (up to ~5000 - // rows) or sleep page built every row subtree twice — double the DOM, double - // the per-row `Intl` work. Gate on the SSR-safe `useIsMobile` so only the - // active layout mounts. The hook resolves to the desktop branch on the server - // and the first client paint (matching the SSR HTML → no hydration - // mismatch), then flips to the live viewport after hydration; the `md:*` - // visibility classes below stay as a belt-and-suspenders visual guard for - // that first frame. No row markup or data-slot changes. + const display = useMeasurementRowDisplay(); + // Only the active layout mounts: a dense day-grouped page used to build + // every row twice, once per layout, with only CSS hiding one. The hook + // resolves to the desktop branch on the server and the first client paint + // (matching the SSR HTML), then follows the viewport; the `md:*` classes + // stay as a guard for that first frame. const isMobile = useIsMobile("md"); - const queryClient = useQueryClient(); const router = useRouter(); - // v1.22 — clicking a row's type badge drills into that metric's Insights - // sub-page when one exists, else into the filtered measurements list. The - // reverse map prefers the dedicated single-metric slug (PULSE → `pulse`, - // WEIGHT → `weight`); types with no sub-page fall back to the `?type=` deep - // link the page already supports. + // A row's type badge drills into that metric's Insights page when there is + // one, else into the list filtered to the type. const navigateForType = (type: string) => { const slug = subPageSlugForType(type); router.push( @@ -403,67 +101,11 @@ export function MeasurementList({ : `/measurements?type=${encodeURIComponent(type)}`, ); }; - // URL-owned filter state (the documents-vault pattern): type, source, date - // range and value range parse from `?type&source&from&to&min&max` and every - // change writes back through the router, so a filtered view survives - // navigation, reload and back/forward. The `?type=` deep links (Vorsorge - // card, type-badge drill-down) now simply ARE the filter state. - // v1.8.5 — when `lockedType` is set the list is pinned to that metric, the - // type selector is hidden, and the URL belongs to the insights subpage — - // the remaining filters stay in component memory (`syncToUrl: false`). - const { filters, applyFilters, patchFilters } = useUrlFilterSync({ - parse: parseMeasurementListSearchParams, - serialise: measurementListFiltersToSearch, - syncToUrl: lockedType === undefined, - }); - const typeFilter = lockedType ?? filters.type ?? "ALL"; - // v1.15.13 — management-list source filter + optional date range. - // `ALL` clears the source filter; empty date strings clear the bound. - const sourceFilter = filters.source ?? "ALL"; - const fromDay = filters.fromDay ?? ""; - const toDay = filters.toDay ?? ""; - // v1.18.5 — value-range filter (backlog G). The inputs fire per keystroke, - // so they edit a local draft that debounces into the URL (200 ms, replace — - // never a history entry per keystroke); an external URL change (back - // button, deep link) re-seeds the draft via the render-phase derived-state - // adjustment below, exactly like the vault's search field. - const urlValueRange = `${filters.valueMin ?? ""}\u0000${filters.valueMax ?? ""}`; - const [valueDraft, setValueDraft] = useState(() => ({ - min: filters.valueMin ?? "", - max: filters.valueMax ?? "", - })); - const [lastUrlValueRange, setLastUrlValueRange] = useState(urlValueRange); - if (urlValueRange !== lastUrlValueRange) { - setLastUrlValueRange(urlValueRange); - setValueDraft({ - min: filters.valueMin ?? "", - max: filters.valueMax ?? "", - }); - } - const valueMinInput = valueDraft.min; - const valueMaxInput = valueDraft.max; - const [page, setPage] = useState(1); - // Shared column-sort state (measuredAt opens descending). - const { - sortBy, - sortDir, - toggleSort: applySort, - } = useTableSort({ - defaultColumn: "measuredAt", - defaultDir: "desc", - descColumns: MEASUREMENT_DESC_COLUMNS, - }); + const filters = useMeasurementListFilters(lockedType); + const { typeFilter, page, clearSelection, goToPage, setTypeFilter } = filters; - // v1.15.13 — page-scoped multi-select. Holds the ids selected on the - // CURRENT page; cleared on any page / filter / sort change (per the - // v1.15.x audit — no "select across 200k rows"). The bulk-delete - // payload is intersected with the painted page ids, so it is always - // page-bounded (≤ PAGE_SIZE) and well under the server's 200 cap. - const [selectedIds, setSelectedIds] = useState>(() => new Set()); - - // v1.4.37 — set of dayKeys whose drill-down is currently - // expanded. Each key maps to a one-shot query that lazy-fetches - // the per-sample rows for that calendar day on first open. + // The day rows whose drill-down is open; each opens a one-shot read of the + // day's samples on first expand. const [expandedDayKeys, setExpandedDayKeys] = useState>( () => new Set(), ); @@ -476,455 +118,29 @@ export function MeasurementList({ }); } - const [editing, setEditing] = useState(null); - const [editValue, setEditValue] = useState(""); - const [editMeasuredAt, setEditMeasuredAt] = useState(""); - const [editNotes, setEditNotes] = useState(""); - const [editSeed, setEditSeed] = useState({ - value: "", - measuredAt: "", - notes: "", - }); - const [editError, setEditError] = useState(null); - const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); - // v1.4.27 — link the edit dialog error banner to the inputs via - // `aria-describedby` so screen readers announce the validation - // failure when the user submits an invalid value or timestamp. - const editErrorId = useId(); - const editErrorDescriptor = editError ? editErrorId : undefined; - // v1.4.27 — Sheet-branch sticky-pinned footer slot. - const editFormId = useId(); - const [editFooterEl, setEditFooterEl] = useState(null); - - // v1.15.13 — every filter / page / sort change resets pagination to - // page 1 AND clears the page-scoped selection (the rows it referred to - // are about to unmount). - const clearSelection = () => setSelectedIds(new Set()); - - // Discrete facet changes push (Back steps through filter states, like the - // vault's chips); the committed date bounds replace — a from/to pair set in - // one sitting should not cost two Back presses. - const setTypeFilter = (value: string) => { - patchFilters({ type: value === "ALL" ? undefined : value }); - setPage(1); - clearSelection(); - }; - - const setSourceFilter = (value: string) => { - patchFilters({ source: value === "ALL" ? undefined : value }); - setPage(1); - clearSelection(); - }; - - const setFromDay = (value: string) => { - patchFilters({ fromDay: value || undefined }, "replace"); - setPage(1); - clearSelection(); - }; - - const setToDay = (value: string) => { - patchFilters({ toDay: value || undefined }, "replace"); - setPage(1); - clearSelection(); - }; - - const setValueMinInput = (value: string) => { - setValueDraft((prev) => ({ ...prev, min: value })); - setPage(1); - clearSelection(); - }; - - const setValueMaxInput = (value: string) => { - setValueDraft((prev) => ({ ...prev, max: value })); - setPage(1); - clearSelection(); - }; - - // The value-range draft debounces into the URL; a bound that does not - // normalise to a finite number never reaches it (matching the previous - // in-memory behaviour, where non-numeric input meant "unset"). - useEffect(() => { - const handle = setTimeout(() => { - const min = numericInputOrUndefined(valueDraft.min); - const max = numericInputOrUndefined(valueDraft.max); - if ( - (min ?? "") === (filters.valueMin ?? "") && - (max ?? "") === (filters.valueMax ?? "") - ) { - return; - } - patchFilters({ valueMin: min, valueMax: max }, "replace"); - }, 200); - return () => clearTimeout(handle); - }, [valueDraft, filters, patchFilters]); - - const goToPage = (updater: (p: number) => number) => { - setPage(updater); - clearSelection(); - }; - - // Compose the page-reset + selection-clear side effects around the - // shared sort toggle (the rows the selection referred to are about to - // re-order and the list snaps back to page 1). - function toggleSort(column: string) { - applySort(column); - setPage(1); - clearSelection(); - } - - // v1.4.37 — when the type filter is a cumulative HK type - // (steps, active energy, distance, flights, daylight) the list - // collapses to one row per user-TZ day via the route's - // `groupBy=day` mode. The chevron in the row drills back into the - // per-sample chunks via a separate `dayKey=…` query. Matches the - // Apple Health.app / Garmin / Withings pattern documented in - // `.planning/research/v1437-step-aggregation.md` §"How the leaders - // do it" — never list per-sample chunks as the default view. - const isCumulativeFilter = CUMULATIVE_TYPES.has(typeFilter); - // v1.11.5 — SLEEP_DURATION is collapsed server-side to one row per - // night (TIME ASLEEP), so the list paints per-night rows with a - // chevron drilling into the night's stage segments. Like the - // cumulative branch it pages server-side over its synthesised rows. - const isSleepFilter = typeFilter === "SLEEP_DURATION"; - const isDayGroupedFilter = isCumulativeFilter || isSleepFilter; - - // v1.15.13 — derive the ISO datetime window the list `from`/`to` - // params require from the two date inputs (local start/end of day). - const fromIso = dayBoundaryIso(fromDay, "start"); - const toIso = dayBoundaryIso(toDay, "end"); - const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter; - // v1.18.5 — value-range bounds. Empty / non-numeric input clears the - // bound. Only threaded on the raw per-sample list; the day-grouped and - // sleep-night modes synthesise rows server-side (a SUM / per-night - // TIME-ASLEEP), where filtering on a raw `value` column carries no - // meaning, so the range pill is hidden for those types. - const valueMin = - filters.valueMin !== undefined ? Number(filters.valueMin) : undefined; - const valueMax = - filters.valueMax !== undefined ? Number(filters.valueMax) : undefined; - const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter - ? "sleep-night" - : isCumulativeFilter - ? "groupBy=day" - : "raw"; - - const { data, isLoading, isError, error, refetch } = useQuery({ - queryKey: queryKeys.measurementsList({ - type: typeFilter === "ALL" ? undefined : typeFilter, - sourceEq, - from: fromIso, - to: toIso, - // Value range applies only to the raw per-sample list. - valueMin: isDayGroupedFilter ? undefined : valueMin, - valueMax: isDayGroupedFilter ? undefined : valueMax, - page, - sortBy, - sortDir, - mode: listMode, - }), - queryFn: async () => { - const params = new URLSearchParams(); - if (typeFilter !== "ALL") params.set("type", typeFilter); - if (sourceEq) params.set("sourceEq", sourceEq); - if (fromIso) params.set("from", fromIso); - if (toIso) params.set("to", toIso); - // v1.18.5 — value range only on the raw per-sample list. - if (!isDayGroupedFilter && valueMin != null) - params.set("valueMin", String(valueMin)); - if (!isDayGroupedFilter && valueMax != null) - params.set("valueMax", String(valueMax)); - params.set("limit", String(PAGE_SIZE)); - params.set("offset", String((page - 1) * PAGE_SIZE)); - params.set("sortBy", sortBy); - params.set("sortDir", sortDir); - // The day-grouped modes page over days, the same 25 per page as - // the per-sample list: `groupBy=day` folds each cumulative day to - // its sum, and SLEEP_DURATION collapses to one row per night - // without a param. - if (isCumulativeFilter) params.set("groupBy", "day"); - return apiGet<{ - measurements: Measurement[]; - meta: { total: number }; - }>(`/api/measurements?${params}`); - }, + const list = useMeasurementListData({ + filters, enabled: isAuthenticated, + lockedType, + onEmptyChange, }); - - const listEmpty = - !isLoading && - !isError && - !data?.measurements?.length && - (typeFilter === "ALL" || !!lockedType); - useEffect(() => { - onEmptyChange?.(listEmpty); - }, [listEmpty, onEmptyChange]); - - // v1.16.4 — deletes are soft (tombstones), so the success toast can - // carry a real Undo: it POSTs the ids to `/api/measurements/restore`, - // which clears `deletedAt` and re-fires the same dependent-key bundle. - // Mirrors the intake-Undo pattern in `use-medication-intake.ts`. - const restoreMeasurements = useCallback( - async (ids: string[]) => { - try { - await apiPost("/api/measurements/restore", { ids }); - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); - toast.success(t("measurements.restoredToast")); - } catch (failure) { - showErrorToast(t("measurements.restoreError"), { cause: failure }); - } - }, - [queryClient, t], - ); - - const deleteMutation = useMutation({ - mutationFn: async (id: string) => { - await apiDelete(`/api/measurements/${id}`); - }, - onSuccess: (_data, id) => { - void invalidateKeys(queryClient, measurementDependentKeys); - void refetchInactiveDailyReads(queryClient); - toast.success(t("measurements.deletedToast"), { - action: { - label: t("common.undo"), - onClick: () => void restoreMeasurements([id]), - }, - }); - }, - // v1.16.4 — a failed row delete used to fail silently (no onError - // handler); the edit-dialog path surfaced its own inline error but - // the row-level confirm had no feedback at all. - onError: (failure) => { - showErrorToast(t("measurements.deleteError"), { cause: failure }); - }, + const { + data, + isLoading, + isError, + error, + refetch, + selectedOnPage, + onConfirmBulkDelete, + bulkDeleteMutation, + totalPages, + } = list; + const edit = useMeasurementEdit({ + onEdit, + display, + deleteMutation: list.deleteMutation, }); - // v1.15.13 — page-scoped bulk soft-delete. Posts the selected ids - // (intersected with the painted page) to the bulk-delete route, then - // invalidates the same dependent-key bundle the single delete busts so - // the dashboard / insights / chart caches stay in lockstep. - const bulkDeleteMutation = useMutation({ - mutationFn: async (ids: string[]) => { - const data = await apiPost<{ deleted: number }>( - "/api/measurements/bulk-delete", - { ids }, - ); - return data.deleted; - }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); - clearSelection(); - toast.success( - deleted === 1 - ? t("measurements.bulkDeleteSuccessOne") - : t("measurements.bulkDeleteSuccess", { count: String(deleted) }), - { - action: { - label: t("common.undo"), - onClick: () => void restoreMeasurements(ids), - }, - }, - ); - }, - onError: (failure) => { - showErrorToast(t("measurements.bulkDeleteError"), { cause: failure }); - }, - }); - - const pageIds = (data?.measurements ?? []) - // Synthetic day-grouped / sleep-night rows aren't individually - // deletable (they collapse many raw rows behind a `dayKey`), so they - // are excluded from the selectable set. - .filter((m) => !(m.dayKey !== undefined && m.sampleCount !== undefined)) - .map((m) => m.id); - - // Selection is held as a raw id set but every read intersects it with - // the painted page (`selectAllState` / `selectedCountOnPage` / - // `selectedIdsOnPage`), so an id that falls off the page after a - // refetch (e.g. a single-row delete) simply stops counting — no stale - // selection survives a page / filter / sort change either, since those - // call `clearSelection()`. No prune effect is needed. - const selectAll = selectAllState(selectedIds, pageIds); - const selectedOnPage = selectedCountOnPage(selectedIds, pageIds); - - function onToggleRow(id: string) { - setSelectedIds((prev) => toggleId(prev, id)); - } - - function onToggleSelectAll() { - setSelectedIds((prev) => toggleSelectAll(prev, pageIds)); - } - - function onConfirmBulkDelete() { - const ids = selectedIdsOnPage(selectedIds, pageIds); - if (ids.length === 0) return; - bulkDeleteMutation.mutate(ids); - } - - const updateMutation = useMutation({ - mutationFn: async ({ - id, - value, - measuredAt, - notes, - }: { - id: string; - value: number; - measuredAt: string; - notes: string | null; - }) => { - await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes }); - }, - onSuccess: async () => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); - setEditSeed({ - value: editValue, - measuredAt: editMeasuredAt, - notes: editNotes, - }); - dismissEdit(); - }, - onError: (err) => { - // v1.4.28 — the PUT route returns a 409 with - // `meta.errorCode === "measurement.duplicate_timestamp"` when the - // edit collides with an existing row's - // `(type, measuredAt, source, sleepStage)` tuple. ApiError carries - // the envelope `meta`, so the localised string can be picked here. - if ( - err instanceof ApiError && - err.meta?.errorCode === "measurement.duplicate_timestamp" - ) { - setEditError(t("measurements.duplicateTimestamp")); - return; - } - // v1.27.5 (di-001) — value edits on server-owned sources - // (connector / import / computed rows) are refused with a 409. - if ( - err instanceof ApiError && - err.meta?.errorCode === "measurement.update.server_owned_source" - ) { - setEditError(t("measurements.serverOwnedSource")); - return; - } - setEditError( - err instanceof Error ? err.message : t("measurements.saveError"), - ); - }, - }); - const editDismissal = useSeededFormDismissal({ - seed: editSeed, - value: { - value: editValue, - measuredAt: editMeasuredAt, - notes: editNotes, - }, - blocked: updateMutation.isPending || deleteMutation.isPending, - }); - - // Every mode pages server-side at PAGE_SIZE: the per-sample list over - // rows, the day-grouped modes over days or nights (#1245). - const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0; - - function startEdit(measurement: Measurement) { - if (onEdit) { - onEdit(measurement); - return; - } - - // Seed the input in the user's preferred unit so an imperial user edits in - // lb / in / °F (converted back to canonical on save). Metric users and - // legacy non-canonical rows take the identity path — the seed is the raw - // stored value, unchanged. Round the converted imperial seed so the input - // shows "210" rather than a long float. - const rd = rowDisplay(measurement); - const seedValue = editsConvertedGlucose(measurement) - ? rd.value - : unitDisplay.preference === "imperial" && - unitDisplay.isTransformed(measurement.type) && - measurement.unit === getUnitForType(measurement.type) - ? Math.round(rd.value * 100) / 100 - : measurement.value; - const seed = { - value: String(seedValue), - measuredAt: toDateTimeLocalValue(measurement.measuredAt), - notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH), - }; - setEditing(measurement); - setEditValue(seed.value); - setEditMeasuredAt(seed.measuredAt); - setEditNotes(seed.notes); - setEditSeed(seed); - setEditError(null); - } - - function dismissEdit() { - setEditing(null); - setEditError(null); - setEditDeleteDialogOpen(false); - } - - function closeEdit() { - editDismissal.requestClose(dismissEdit); - } - - async function deleteEditingMeasurement() { - if (!editing) return; - - try { - setEditError(null); - await deleteMutation.mutateAsync(editing.id); - dismissEdit(); - } catch { - setEditError(t("measurements.deleteError")); - } - } - - async function submitEdit(e: React.FormEvent) { - e.preventDefault(); - if (!editing) return; - - const parsedValue = Number(editValue); - if (!Number.isFinite(parsedValue)) { - setEditError(t("measurements.invalidValue")); - return; - } - - const measuredDate = new Date(editMeasuredAt); - if (Number.isNaN(measuredDate.getTime())) { - setEditError(t("measurements.invalidTimestamp")); - return; - } - - // The input holds a display-unit value; invert it to canonical SI before - // the PATCH so an imperial edit persists in the stored unit. Metric users - // and legacy non-canonical rows are the identity path — value unchanged. - let canonicalValue = parsedValue; - if (editsConvertedGlucose(editing)) { - // The label above the input said mmol/L, so the number in it is - // mmol/L. Sending it on would rewrite a 95 mg/dL reading as 5.3. - canonicalValue = toCanonicalMgdl(parsedValue, "mmol/L"); - } else if ( - unitDisplay.isTransformed(editing.type) && - editing.unit === getUnitForType(editing.type) - ) { - const inverted = unitDisplay.fromDisplay(editing.type, parsedValue); - canonicalValue = - unitDisplay.preference === "imperial" - ? Math.round(inverted * 100) / 100 - : inverted; - } - - setEditError(null); - updateMutation.mutate({ - id: editing.id, - value: canonicalValue, - measuredAt: measuredDate.toISOString(), - notes: editNotes.trim() ? editNotes.trim() : null, - }); - } - if (!isAuthenticated) { return (

@@ -933,17 +149,22 @@ export function MeasurementList({ ); } + const rowProps = { + canManage, + filters, + list, + display, + expandedDayKeys, + toggleExpand, + navigateForType, + startEdit: (m: Measurement) => edit.startEdit(m), + }; + return ( <>

- {/* v1.16.1 — unified filter rail (``): compact pill - triggers in the canonical order date range · type · source, - active filters fold into their pill as removable chips, plus - a reset action and the result count. Filter state and query - keys are unchanged — this swap is presentation-only. - On the locked-type insights subpage the rail is suppressed - entirely (the metric is fixed by the route); only the count - line remains. */} + {/* The filter rail; on the locked-type page (the metric is fixed by + the route) only the count line remains. */} {lockedType ? ( data?.meta?.total !== undefined && (
@@ -955,76 +176,7 @@ export function MeasurementList({
) ) : ( - { - // One push clears every facet (and so one Back restores them); - // the value-range draft is cleared directly because a bound - // that never committed to the URL has no change to re-seed - // from. - applyFilters({}); - setValueDraft({ min: "", max: "" }); - setPage(1); - clearSelection(); - }} - count={ - data?.meta?.total !== undefined - ? t("measurements.measurementCount", { - count: fmt.integer(data.meta.total), - }) - : undefined - } - > - - ({ value: val, label: t(labelKey) }), - )} - /> - ({ - value: src, - label: formatMeasurementSource(src, t), - }))} - /> - {/* v1.18.5 — value-range pill. Hidden for the day-grouped / - sleep-night types where the painted `value` is a synthesised - SUM / per-night total rather than a raw reading. */} - {!isDayGroupedFilter && ( - - )} - + )} {isLoading ? ( @@ -1038,597 +190,31 @@ export function MeasurementList({ // an outage never reads as "you have no measurements". void refetch()} /> ) : !data?.measurements?.length ? ( - <> - {/* v1.4.15: replaces the bare-text empty rectangle. - Filter-aware copy distinguishes brand-new accounts ("no - measurements yet") from filtered-out lists ("no measurements - match this filter") and exposes the right CTA for each case. */} - } - title={ - typeFilter === "ALL" || lockedType - ? t("measurements.emptyTitle") - : t("measurements.emptyFilteredTitle") - } - description={ - typeFilter === "ALL" || lockedType - ? t("measurements.emptyDescription") - : t("measurements.emptyFilteredDescription") - } - action={ - // v1.8.5 — a locked-type list has no "reset filter" path; - // the metric is fixed by the route. - typeFilter !== "ALL" && !lockedType ? ( - - ) : onAddFirst && canAddMeasurement ? ( - - ) : undefined - } - /> - {/* The one place in the app the asymmetry is spelled out. A - delegate who may add will look for the edit control beside the - row they just entered, so the list that carries the add button - says once, in one muted line, where the limit is. Every other - hidden control stays silent: the banner already names the mode, - and a chip beside each absent button would be noise. */} - {delegateName && ( -

- {t("recordSharing.delegate.addOnlyNote", { - name: delegateName, - })} -

- )} - + setTypeFilter("ALL")} + onAddFirst={canAddMeasurement ? onAddFirst : undefined} + delegateName={delegateName} + /> ) : ( <> - {/* Desktop table — rendered only when not mobile (v1.28.42 H3). */} + {/* Desktop table or phone list, whichever layout is active. */} {!isMobile && ( -
- - - - - {/* v1.15.13 — select-all-on-page header checkbox. - v1.36.x — bulk delete is the owner's alone, so a - delegate gets no selection column. */} - {canManage && ( - - )} - - - {t("measurements.type")} - - - - - {t("measurements.comment")} - - - - {t("measurements.actions")} - - - - - {data.measurements.map((m) => { - const isGrouped = - m.dayKey !== undefined && m.sampleCount !== undefined; - // v1.11.5 — sleep night rows are grouped (chevron - // drills into the stage segments) but render a - // sleep-aware headline + nap caption, not the - // cumulative "daily total" caption. - const isSleep = m.type === "SLEEP_DURATION"; - const isExpanded = isGrouped - ? expandedDayKeys.has(m.dayKey as string) - : false; - // v1.4.38 — stable drill-down id so the - // disclosure chevron can thread aria-controls to the - // expanded panel. dayKey is unique per row when - // grouped; fall back to m.id otherwise. - const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`; - const isSelected = selectedIds.has(m.id); - return ( - - - - {/* Grouped/synthetic rows aren't individually - deletable, so they have no checkbox. */} - {!isGrouped && canManage && ( - onToggleRow(m.id)} - aria-label={t("dataList.selectRow")} - /> - )} - - - - - - - - {/* v1.4.39.3 — non-grouped rows render the - stored value with its native precision - (`fmt.number` honours up to 3 fraction - digits by default, drops trailing zeros) - so a 78.4 kg weight reading stops - truncating to "78". Grouped rows are - cumulative-type day aggregates (steps, - distance, etc.) and stay on `fmt.integer` - because the underlying readings are - integer-only by definition. - v1.11.5 — sleep rows render the night's - TIME ASLEEP as "8h 12m" + a nap caption. */} - {isSleep ? ( - <> - {formatDurationMinutes(m.value, t)} - - - ) : ( - <> - {isGrouped - ? fmt.integer(m.value) - : fmt.number( - rowDisplay(m).value, - rawDisplayFractionDigits(m.type), - )}{" "} - {measurementUnitLabel( - m.type, - isGrouped ? m.unit : rowDisplay(m).unit, - t, - )} - {isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer( - m.sampleCount as number, - ), - })} - - )} - - )} - - {/* - v1.4.43 — `formatDateOrRelative` - renders timestamps inside the last 24 h - as "vor 3 min" so a fresh entry visible - on the dashboard briefing and on the - list view never disagrees about how to - phrase "when". - */} - - {/* v1.42 — the date opens its day; the row - keeps opening the reading. */} - {m.dayKey !== undefined ? ( - - {formatDateOrRelative(m.measuredAt, t)} - - ) : ( - - {formatDateOrRelative(m.measuredAt, t)} - - )} - - - {m.notes ? ( - - {truncateComment(m.notes)} - - ) : ( - - - )} - - - {m.source !== "MANUAL" && ( - - {formatMeasurementSource(m.source, t)} - - )} - - -
- {isGrouped ? ( - - ) : ( - <> - {canManage && ( - - )} - - deleteMutation.mutate(m.id) - } - title={t( - "measurements.deleteConfirmTitle", - )} - description={t( - "measurements.deleteConfirmDescription", - )} - /> - - )} -
-
-
- {isGrouped && isExpanded && ( - - - - - - )} -
- ); - })} -
-
-
+ )} - - {/* Mobile list — rendered only when mobile (v1.28.42 H3). */} {isMobile && ( -
- {data.measurements.map((m) => { - const Icon = TYPE_ICONS[m.type]; - const isGrouped = - m.dayKey !== undefined && m.sampleCount !== undefined; - const isSleep = m.type === "SLEEP_DURATION"; - const isExpanded = isGrouped - ? expandedDayKeys.has(m.dayKey as string) - : false; - // v1.4.38 — see desktop counterpart. - const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`; - const isSelected = selectedIds.has(m.id); - return ( - -
-
- {/* v1.15.13 — multi-select checkbox in a 44px tap - target; absent for synthetic grouped rows. */} - {!isGrouped && canManage && ( - // v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox - // (itself a ` - )} -
- {/* v1.22 — metadata badges sit on the scale - token `text-xs` (12 px, the mobile legibility - baseline) instead of an arbitrary per-site - size, so the row tracks the type scale. */} - {/* The type names the row: without it two - readings in the same unit ("55 ms" HRV and - "63 ms" RMSSD) could not be told apart on a - phone, where the desktop table's type column - does not exist. */} -

- {TYPE_LABEL_KEYS[m.type] - ? t(TYPE_LABEL_KEYS[m.type]) - : m.type} -

- - {/* v1.4.39.3 — mirror the desktop table: - grouped daily aggregates stay integer, - non-grouped single readings render with - their native decimal precision so - "78.4 kg" no longer truncates to "78". - v1.11.5 — sleep rows render TIME ASLEEP. */} - {isSleep - ? formatDurationMinutes(m.value, t) - : isGrouped - ? fmt.integer(m.value) - : fmt.number( - rowDisplay(m).value, - rawDisplayFractionDigits(m.type), - )} - - {isSleep ? ( - - ) : ( - isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer(m.sampleCount as number), - })} - - ) - )} - {/* The meta line wraps rather than truncates: - cut to "vor 9 Stunden…" it hid the day it - opens and the source beside it. */} -

- {/* - v1.4.43 — see desktop - counterpart at the same `measuredAt` - site. Relative under 24 h, absolute - older. - */} - {/* The unit is meta, and on a phone a long - one ("Atemzüge/min") beside the value pushed - the row's controls; it leads the meta line - instead. Sleep reads as a duration. */} - {(() => { - if (isSleep) return null; - const lead = measurementUnitMetaLead( - m.type, - isGrouped ? m.unit : rowDisplay(m).unit, - t, - ); - return lead ? ( - - {lead} - - ) : null; - })()} - {m.dayKey !== undefined ? ( - - {formatDateOrRelative(m.measuredAt, t)} - - ) : ( - - {formatDateOrRelative(m.measuredAt, t)} - - )} - {m.source !== "MANUAL" && ( - - {formatMeasurementSource(m.source, t)} - - )} -

- {m.notes && ( - // User data is never muted — the desktop table - // renders the same note in the foreground colour. -

- {truncateComment(m.notes)} -

- )} -
-
-
- {isGrouped ? ( - - ) : ( - <> - {canManage && ( - - )} - deleteMutation.mutate(m.id)} - title={t("measurements.deleteConfirmTitle")} - description={t( - "measurements.deleteConfirmDescription", - )} - /> - - )} -
-
- {isGrouped && isExpanded && ( -
- -
- )} -
- ); - })} -
+ )} )} - {/* v1.15.13 — page-scoped multi-select action bar. */} + {/* Page-scoped multi-select action bar. */} - {/* Pagination */} {totalPages > 1 && ( -
- - {t("measurements.pageInfo", { - page: String(page), - total: String(totalPages), - })} - -
- - -
-
+ )}
- !open && closeEdit()} - title={t("measurements.editMeasurement")} - footer={
} - > - {editing && ( -
-
- - - {TYPE_LABEL_KEYS[editing.type] - ? t(TYPE_LABEL_KEYS[editing.type]) - : editing.type} - -
- -
- - setEditValue(e.target.value)} - required - aria-required="true" - aria-invalid={!!editError || undefined} - aria-describedby={editErrorDescriptor} - /> -
- -
- - -
- -
-
- - - {editNotes.length}/{MAX_COMMENT_LENGTH} - -
- setEditNotes(e.target.value)} - maxLength={MAX_COMMENT_LENGTH} - enterKeyHint="done" - autoCapitalize="sentences" - /> -
- - {editError && ( - - )} - - {editFooterEl - ? createPortal( -
- - - - - - setEditDeleteDialogOpen(true)} - > - - {t("common.delete")} - - - - - {/* - v1.4.43 — `[Cancel] [Save]` order is - iOS-first intentional per Apple HIG, which - puts the primary / confirmation action - rightmost. iOS is the load-bearing mobile - target for HealthLog and the web shell renders - desktop + iOS PWA from the same component, so - the current order survives. Do NOT flip to - `[Save] [Cancel]` in a future "Android parity" - refactor — fork a platform-specific shell - instead if Android becomes a real audience. - */} -
- - -
-
, - editFooterEl, - ) - : null} - - - - - - {t("measurements.deleteConfirmTitle")} - - - {t("measurements.deleteConfirmDescription")} - - - - - {t("common.cancel")} - - - {deleteMutation.isPending ? ( - - ) : null} - {t("common.delete")} - - - - -
- )} - - + ); } /** - * v1.4.37 — lazy drill-down for a collapsed cumulative-type day. - * Fires a separate `GET /api/measurements?type=…&dayKey=…` only when - * the row is expanded; the result is rendered as a nested list of the - * per-sample chunks that summed into the parent row's daily total. + * Nothing to list. The copy tells a new account ("no measurements yet") from + * a filtered-out list, with the action each one needs; a locked-type list has + * no filter to reset, since the route fixes the metric. */ -function DayDrillDown({ - type, - dayKey, - unit, - layout, +function MeasurementListEmpty({ + filtered, + onResetFilter, + onAddFirst, + delegateName, }: { - type: string; - dayKey: string; - unit: string; - layout: "desktop" | "mobile"; + filtered: boolean; + onResetFilter: () => void; + onAddFirst?: () => void; + delegateName: string | null; }) { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); - const { data, isLoading, error, refetch } = useQuery({ - queryKey: queryKeys.measurementDrilldown(type, dayKey), - queryFn: async () => { - const params = new URLSearchParams(); - params.set("type", type); - params.set("dayKey", dayKey); - params.set("sortDir", "asc"); - return apiGet<{ measurements: Measurement[] }>( - `/api/measurements?${params}`, - ); - }, - enabled: isAuthenticated, - // The drill-down is per-day — once fetched it rarely needs to - // re-fetch (the underlying day has already stabilised for - // anything older than the drain's 36 h cutoff). Keep it cached - // for the session. - staleTime: 5 * 60 * 1000, - }); - - if (isLoading) { - return ( -
- - {t("common.loading")} -
- ); - } - if (error) { - return ( - void refetch()} - className={layout === "desktop" ? "m-2" : "mt-2"} - /> - ); - } - const rows = data?.measurements ?? []; - if (rows.length === 0) { - return ( - - ); - } - // v1.11.5 — for SLEEP_DURATION the drill-down rows are stage segments; - // render the stage label + minutes instead of a bare value + unit. - const isSleep = type === "SLEEP_DURATION"; - if (layout === "desktop") { - return ( -
- {rows.map((s) => ( -
- - {isSleep - ? sleepStageLabel(s.sleepStage, t) - : formatDateTime(s.measuredAt)} - - - {isSleep - ? formatDurationMinutes(s.value, t) - : `${s.value} ${unit}`} - -
- ))} -
- ); - } return ( -
- {rows.map((s) => ( -
+ } + title={ + filtered + ? t("measurements.emptyFilteredTitle") + : t("measurements.emptyTitle") + } + description={ + filtered + ? t("measurements.emptyFilteredDescription") + : t("measurements.emptyDescription") + } + action={ + filtered ? ( + + ) : onAddFirst ? ( + + ) : undefined + } + /> + {/* The one place in the app the asymmetry is spelled out. A delegate + who may add will look for the edit control beside the row they just + entered, so the list that carries the add button says once, in one + muted line, where the limit is. Every other hidden control stays + silent: the banner already names the mode. */} + {delegateName && ( +

- - {isSleep - ? sleepStageLabel(s.sleepStage, t) - : formatDateTime(s.measuredAt)} - - - {isSleep ? formatDurationMinutes(s.value, t) : `${s.value} ${unit}`} - -

- ))} + {t("recordSharing.delegate.addOnlyNote", { + name: delegateName, + })} +

+ )} + + ); +} + +function MeasurementListPagination({ + page, + totalPages, + goToPage, +}: { + page: number; + totalPages: number; + goToPage: (updater: (p: number) => number) => void; +}) { + const { t } = useTranslations(); + return ( +
+ + {t("measurements.pageInfo", { + page: String(page), + total: String(totalPages), + })} + +
+ + +
); } - -/** - * Sub-caption for a per-night sleep row: the row's value is the day's sleep, - * main night plus naps, so the caption says how many naps it includes, and - * the night's mid-sleep awakenings when present. - */ -function SleepNightCaption({ m }: { m: Measurement }) { - const { t } = useTranslations(); - const napCount = m.napCount ?? 0; - const awakenings = m.awakenings ?? 0; - const parts: string[] = []; - if (napCount > 0) { - parts.push(t("measurements.sleepNapCaption", { count: String(napCount) })); - } - if (awakenings > 0) { - parts.push( - t("measurements.sleepAwakeningsCaption", { count: String(awakenings) }), - ); - } - if (parts.length === 0) return null; - return ( - - {parts.join(" · ")} - - ); -} From 3eb5aa93640918b921f4a009b8f19261bb9d4698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:35:46 +0200 Subject: [PATCH 05/35] refactor(documents): split the vault page into filters, library, selection and detail DocumentsView carried 970 lines of state before its first element. The state moves to components/documents/vault/: - selection-history: the `?doc=` history entry helpers (pure, tested) - use-vault-filters: URL filters, the debounced search and the `/` key - use-vault-library: usage, the paged list and its processing poll, layout, uploads and their intake paths, the corpus-index affordance - use-vault-filter-choices: conditions, procedures, visits, years - use-vault-selection and use-vault-bulk: the selection with ranges and Escape, the confirmed delete, re-filing, linking and the bulk share - use-vault-detail: the detail sheet driven by `?doc=`, its close and the check that the close took - vault-page-actions, vault-drop-overlay The page composes them in about 300 lines; markup and behaviour are unchanged, and the guards follow the code they watch. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 5 +- .../client-poll-inventory-guard.test.ts | 2 +- .../destructive-control-guard.test.ts | 3 +- .../success-affordance-guard.test.ts | 7 +- src/__tests__/url-state-writes-guard.test.ts | 2 +- .../documents-delete-confirm-guard.test.ts | 6 +- .../documents-view-navigation.test.ts | 2 +- src/components/documents/documents-view.tsx | 1049 ++--------------- .../documents/vault/selection-history.ts | 148 +++ .../documents/vault/use-vault-bulk.ts | 176 +++ .../documents/vault/use-vault-detail.ts | 180 +++ .../vault/use-vault-filter-choices.ts | 138 +++ .../documents/vault/use-vault-filters.ts | 134 +++ .../documents/vault/use-vault-library.ts | 205 ++++ .../documents/vault/use-vault-selection.ts | 134 +++ .../documents/vault/vault-drop-overlay.tsx | 30 + .../documents/vault/vault-page-actions.tsx | 70 ++ 19 files changed, 1328 insertions(+), 972 deletions(-) create mode 100644 src/components/documents/vault/selection-history.ts create mode 100644 src/components/documents/vault/use-vault-bulk.ts create mode 100644 src/components/documents/vault/use-vault-detail.ts create mode 100644 src/components/documents/vault/use-vault-filter-choices.ts create mode 100644 src/components/documents/vault/use-vault-filters.ts create mode 100644 src/components/documents/vault/use-vault-library.ts create mode 100644 src/components/documents/vault/use-vault-selection.ts create mode 100644 src/components/documents/vault/vault-drop-overlay.tsx create mode 100644 src/components/documents/vault/vault-page-actions.tsx diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 8f2f24790..2bee410d8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 119, - "total": 137765, + "count": 118, + "total": 136542, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -16,7 +16,6 @@ "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, "components/documents/document-detail-sheet.tsx": 1304, - "components/documents/documents-view.tsx": 1223, "components/insights/coach-panel/chat-bubble.tsx": 888, "components/insights/coach-panel/coach-conversation.tsx": 1286, "components/insights/coach-panel/use-coach.ts": 1486, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 19a5f68ca..93dcdaa32 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 405, - "total": 140971, + "total": 140305, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -127,7 +127,7 @@ "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, "components/documents/document-record-links.tsx :: DocumentRecordLinks": 202, "components/documents/document-timeline.tsx :: DocumentTimeline": 314, - "components/documents/documents-view.tsx :: DocumentsView": 969, + "components/documents/documents-view.tsx :: DocumentsView": 303, "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 565, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 2705ce464..e546e423d 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2951, - "total": 10297, + "count": 2950, + "total": 10294, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -836,7 +836,6 @@ "components/documents/document-detail-sheet.tsx": 6, "components/documents/document-facts-review.tsx": 2, "components/documents/document-summary-block.tsx": 2, - "components/documents/documents-view.tsx": 3, "components/documents/use-content-index.ts": 1, "components/documents/use-document-assist.ts": 1, "components/documents/use-document-facts.ts": 1, diff --git a/src/__tests__/client-poll-inventory-guard.test.ts b/src/__tests__/client-poll-inventory-guard.test.ts index c3e14afa2..aa9df73ba 100644 --- a/src/__tests__/client-poll-inventory-guard.test.ts +++ b/src/__tests__/client-poll-inventory-guard.test.ts @@ -89,7 +89,7 @@ const INVENTORY: Readonly> = { shape: "until-done", cadence: "2 s until no restore is active", }, - "components/documents/documents-view.tsx": { + "components/documents/vault/use-vault-library.ts": { sites: 1, shape: "until-done", cadence: diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index d7ad190f2..023c98402 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -606,9 +606,10 @@ const REGISTRY: DestructiveEntry[] = [ ], }, { - file: "components/documents/documents-view.tsx", + file: "components/documents/vault/use-vault-bulk.ts", destroys: "every selected document, or one document from a focused card", triggers: [ + "components/documents/documents-view.tsx", "components/documents/document-bulk-bar.tsx", "components/documents/document-card.tsx", ], diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index f07694e03..335674dfe 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -177,7 +177,12 @@ const PINNED_AFFORDANCES: Record< "src/components/dashboard/range-display.tsx": { "text-success": 2 }, "src/components/documents/document-card.tsx": { "text-success": 1 }, "src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 }, - "src/components/documents/documents-view.tsx": { "toast.success": 3 }, + "src/components/documents/vault/use-vault-library.ts": { + "toast.success": 1, + }, + "src/components/documents/vault/use-vault-bulk.ts": { + "toast.success": 2, + }, // The document picker's per-row result: tinted only for the `imported` / // `duplicate` outcome the import route returned for that one document, i.e. // a document the vault now holds. The run's headline goes through diff --git a/src/__tests__/url-state-writes-guard.test.ts b/src/__tests__/url-state-writes-guard.test.ts index 8c0740162..bb4a087f9 100644 --- a/src/__tests__/url-state-writes-guard.test.ts +++ b/src/__tests__/url-state-writes-guard.test.ts @@ -24,7 +24,7 @@ const ALLOWED: Record = { "lib/url-state.ts": "the helper itself", "components/clinician/share-unlock-gate.tsx": "scrubs a passphrase from the fragment synchronously, before the unlock request leaves; no query state", - "components/documents/documents-view.tsx": + "components/documents/vault/selection-history.ts": "closeDocumentSelectionHistoryEntry takes a history-shaped argument for its tests; the page passes `urlStateHistory`", }; 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..8bc66631d 100644 --- a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts +++ b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts @@ -17,7 +17,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const dir = join(process.cwd(), "src/components/documents"); -const view = readFileSync(join(dir, "documents-view.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 = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8"); const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8"); 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/documents-view.tsx b/src/components/documents/documents-view.tsx index 12b6614ee..da5c67a9d 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -1,5 +1,7 @@ "use client"; +"use client"; + /** * The Dokumente vault — one wide, calm, filing-cabinet-fast surface. * @@ -14,260 +16,54 @@ * module bounces home. Every `/api/documents/inbound/*` route re-enforces * the gate server-side — this is a UX redirect, not the security boundary. */ -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { - FolderOpen, - Loader2, - SearchX, - Upload, - UploadCloud, - Wrench, -} from "lucide-react"; -import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/errors/error-toast"; +import { useEffect } from "react"; +import { FolderOpen, Loader2, SearchX, Upload } from "lucide-react"; -import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useTranslations } from "@/lib/i18n/context"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; -import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; -import { - currentUrlSearch, - urlStateHistory, - useUrlSearch, - writeUrlState, -} from "@/lib/url-state"; -import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; -import { apiGet, apiPost } from "@/lib/api/api-fetch"; -import { - bodySiteText, - encounterKindText, -} from "@/components/encounters/encounter-labels"; -import { useEncounters } from "@/hooks/use-encounters"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import type { EncounterKind } from "@/generated/prisma/client"; -import { - type CoachCloseIntent, - useCoachLaunch, -} from "@/lib/insights/coach-launch-context"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; -import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; -import type { - DocumentBulkAction, - DocumentBulkResultDto, - DocumentUsageDto, - InboundDocumentDetailDto, - InboundDocumentDto, - InboundDocumentKindValue, -} from "@/lib/validations/inbound-documents"; -import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants"; + import { DocumentBulkBar } from "./document-bulk-bar"; import { DocumentDetailSheet } from "./document-detail-sheet"; +import { DocumentFilterBar } from "./document-filter-bar"; import { DocumentShareSheet } from "./document-share-sheet"; -import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar"; import { DocumentTimeline } from "./document-timeline"; import { UploadZone } from "./upload-zone"; -import { ImportFromSourceButton } from "./sources/import-from-source-button"; -import { useReindexAll } from "./use-content-index"; -import { useDocumentUpload } from "./use-document-upload"; -import { usePageFileDrop } from "./use-page-file-drop"; -import { - buildProcedureChoices, - buildVaultListApiSearch, - countActiveFilters, - documentDateKey, - expandRangeSelection, - hasProcessingDocument, - parseVaultSearchParams, - type ProcedureFilterChoice, - resolveBulkShareDocuments, - SHARE_LINK_MAX_DOCUMENTS, - vaultFiltersToSearch, -} from "./vault-utils"; - -interface ListPage { - documents: InboundDocumentDto[]; - nextCursor: string | null; -} - -// Strict end-of-input: `$` also accepts a trailing newline in JavaScript. -/** - * How many visits the bulk bar offers. A menu is a picker, not a history: past - * the first screenful a person scrolls rather than reads, and filing against a - * visit older than this belongs on the visit's own sheet. - */ -const BULK_VISIT_OPTIONS = 12; - -const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/; -const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection"; - -export function documentSelectionHistoryState( - documentId: string, -): Record { - // Pass only application state here. Next's patched pushState copies its - // private router fields onto this object; copying history.state ourselves - // would include `__NA` and make that patch treat this as an internal write, - // skipping useSearchParams synchronization. - return { [DOCUMENT_SELECTION_HISTORY_KEY]: documentId }; -} - -export function documentSelectionHref( - pathname: string, - currentSearch: string, - documentId: string, -): string | null { - if (!DOCUMENT_QUERY_ID_PATTERN.test(documentId)) return null; - const next = new URLSearchParams(currentSearch); - next.set("doc", documentId); - const search = next.toString(); - return search ? `${pathname}?${search}` : pathname; -} - -export function withoutDocumentSelectionHref( - pathname: string, - currentSearch: string, -): string { - const next = new URLSearchParams(currentSearch); - next.delete("doc"); - const search = next.toString(); - return search ? `${pathname}?${search}` : pathname; -} +import { countActiveFilters } from "./vault-utils"; +import { useVaultDetail } from "./vault/use-vault-detail"; +import { useVaultFilters } from "./vault/use-vault-filters"; +import { useVaultLibrary } from "./vault/use-vault-library"; +import { useVaultSelection } from "./vault/use-vault-selection"; +import { VaultDropOverlay } from "./vault/vault-drop-overlay"; +import { VaultPageActions } from "./vault/vault-page-actions"; /** - * How long to wait before checking that `history.back()` actually happened. - * - * Long enough for a traversal to commit on a loaded machine, short enough that - * a reader who closed the sheet does not see the selection linger in the - * address bar. The window is bounded on the other side too: the effect that - * arms this cancels it as soon as the sheet re-opens. + * The vault page. Its state lives in `./vault/`: the URL filters, the + * library (list, usage, uploads, filter choices), the selection and its bulk + * actions, and the `?doc=` detail sheet; this component lays them out. */ -export const CLOSE_TRAVERSAL_GRACE_MS = 250; - -/** - * The selection this close was meant to consume is still in the address bar. - * - * Matched on the id rather than on the presence of `doc`, so a traversal that - * landed somewhere carrying a DIFFERENT selection is not mistaken for a - * dropped one and stripped. - */ -export function documentSelectionSurvivedClose( - search: string, - documentId: string, -): boolean { - return new URLSearchParams(search).get("doc") === documentId; -} - -export function closeDocumentSelectionHistoryEntry( - history: { - readonly state: unknown; - back: () => void; - replaceState: ( - data: unknown, - unused: string, - url?: string | URL | null, - ) => void; - }, - pathname: string, - currentSearch: string, - documentId: string, -): void { - const ownsCurrentEntry = - history.state !== null && - typeof history.state === "object" && - (history.state as Record)[ - DOCUMENT_SELECTION_HISTORY_KEY - ] === documentId; - if (ownsCurrentEntry) { - // Card selection owns the current pushed entry. Returning to its base - // consumes it, so the user's next Back reaches the prior distinct page. - history.back(); - return; - } - - // A deep link did not create a disposable entry on this page. Keep the user - // here and remove only the selection from the current URL. - history.replaceState( - null, - "", - withoutDocumentSelectionHref(pathname, currentSearch), - ); -} - -/** - * The selection is being KEPT on purpose, as the way back. - * - * Maximizing the Coach drawer hands the conversation to `/coach` and carries - * `?doc=` with it, so browser-Back can reconstruct the sheet. That is the one - * close-shaped transition that must leave the parameter exactly where it is. - * - * Named, and used by both the close and the check that follows it, because - * those two disagreeing is a bug with no symptom until it has one: the check - * would strip the return URL a few hundred milliseconds after the close - * deliberately preserved it, and whether it won that race was a question about - * how fast the machine navigated. - */ -export function coachHandoffRetainsSelection( - documentId: string, - handedOffDocumentId: string | null, - closeIntent: CoachCloseIntent | null | undefined, -): boolean { - return handedOffDocumentId === documentId && closeIntent === "navigate"; -} - -export function closeDocumentSelectionAfterCoachHandoff( - history: Parameters[0], - pathname: string, - currentSearch: string, - documentId: string, - handedOffDocumentId: string | null, - closeIntent: CoachCloseIntent | null | undefined, -): string | null { - if ( - coachHandoffRetainsSelection(documentId, handedOffDocumentId, closeIntent) - ) { - return handedOffDocumentId; - } - - closeDocumentSelectionHistoryEntry( - history, - pathname, - currentSearch, - documentId, - ); - return handedOffDocumentId === documentId ? null : handedOffDocumentId; -} - export function DocumentsView() { const { t } = useTranslations(); - const format = useFormatters(); const { user, isLoading: authLoading, isAuthenticated } = useAuth(); const timezone = user?.timezone || DEFAULT_TIMEZONE; - // v1.36.x — uploading a document, filing it, sharing it and asking the AI - // about it are none of them delegated verbs. Inside somebody else's record - // the vault reads and nothing more: no upload path, no selection, no bulk - // bar, no corpus backfill. `canManageDomain("documents")` says so from the - // server's table, and answers false under every grant today. + // Uploading a document, filing it, sharing it and asking the AI about it + // are none of them delegated verbs. Inside somebody else's record the vault + // reads and nothing more: no upload path, no selection, no bulk bar, no + // corpus backfill. `canManageDomain("documents")` says so from the server's + // table, and answers false under every grant today. const { canManageDomain, inSharedRecord, sections } = useRecordCapabilities(); const canManageDocuments = canManageDomain("documents"); const router = useRouter(); const pathname = usePathname(); - const queryClient = useQueryClient(); - const coachLaunch = useCoachLaunch(); const moduleEnabled = user?.modules?.inboundDocuments === true; @@ -281,677 +77,67 @@ export function DocumentsView() { } }, [authLoading, isAuthenticated, moduleEnabled, router]); - // ── URL-owned filter state ──────────────────────────────────────────── - // The parse/serialise round trip stays in `vault-utils.ts`; the router - // plumbing lives in the shared hook (which the measurements and mood - // lists reuse — this surface is the pattern's origin). - const { filters, applyFilters } = useUrlFilterSync({ - parse: parseVaultSearchParams, - serialise: vaultFiltersToSearch, - }); - - // Search draft debounces into the URL (200 ms); an external URL change - // (back button, deep link) re-seeds the draft — render-phase derived-state - // adjustment, not an effect. - const [searchDraft, setSearchDraft] = useState(filters.q ?? ""); - const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? ""); - const searchInputRef = useRef(null); - if ((filters.q ?? "") !== lastUrlQ) { - setLastUrlQ(filters.q ?? ""); - setSearchDraft(filters.q ?? ""); - } - useEffect(() => { - const handle = setTimeout(() => { - const trimmed = searchDraft.trim(); - if (trimmed === (filters.q ?? "")) return; - // Live filters, not the rendered ones a "Clear filters" just removed. - applyFilters( - (current) => - trimmed === (current.q ?? "") - ? current - : { ...current, q: trimmed === "" ? undefined : trimmed }, - "replace", - ); - }, 200); - return () => clearTimeout(handle); - }, [searchDraft, filters, applyFilters]); - - // `/` focuses the search from anywhere on the page. - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) - return; - const target = event.target as HTMLElement | null; - if ( - target && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" || - target.isContentEditable) - ) { - return; - } - event.preventDefault(); - searchInputRef.current?.focus(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - // Every facet toggles against the live filters (`applyFilters` updater). - const toggleKind = (kind: InboundDocumentKindValue) => { - applyFilters((current) => { - const kinds = new Set(current.kinds ?? []); - if (kinds.has(kind)) kinds.delete(kind); - else kinds.add(kind); - return { - ...current, - kinds: kinds.size > 0 ? [...kinds].sort() : undefined, - }; - }, "push"); - }; - - const toggleEpisode = (episodeId: string) => { - applyFilters( - (current) => ({ - ...current, - episodeId: current.episodeId === episodeId ? undefined : episodeId, - }), - "push", - ); - }; - - const toggleEncounter = (encounterId: string) => { - applyFilters( - (current) => ({ - ...current, - encounterId: - current.encounterId === encounterId ? undefined : encounterId, - }), - "push", - ); - }; - - const toggleYear = (year: number) => { - applyFilters( - (current) => ({ - ...current, - year: current.year === year ? undefined : year, - }), - "push", - ); - }; - - const clearFilters = () => { - setSearchDraft(""); - applyFilters({}, "push"); - }; - - // ── Data ────────────────────────────────────────────────────────────── - const usage = useQuery({ - queryKey: queryKeys.inboundDocumentUsage(), - enabled: moduleEnabled, - queryFn: () => apiGet("/api/documents/inbound/usage"), - }); - - const list = useInfiniteQuery({ - queryKey: queryKeys.inboundDocumentList(filters), - enabled: moduleEnabled, - queryFn: ({ pageParam }) => - apiGet( - `/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`, - ), - initialPageParam: null as string | null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - // v1.29.x — the auto-index (+ optional AI summary) job that lands right - // after upload is fire-and-forget server-side; nothing pushes its - // completion to the client. Poll while any loaded document is still - // inside its recent-upload processing window (see `hasProcessingDocument` - // — bounded, so an old/permanently-unindexed document never keeps this - // polling forever) so the card's "Processing…" chip clears on its own - // once the index lands, without a manual refresh. - refetchInterval: (query) => { - const pages = query.state.data?.pages ?? []; - const docs = pages.flatMap((p) => p.documents); - return hasProcessingDocument(docs) ? 4000 : false; - }, - }); - - const documents = useMemo( - () => list.data?.pages.flatMap((p) => p.documents) ?? [], - [list.data], - ); - - // The reader's presentation (cards / list, stacked / flowing months), - // persisted per user and changed behind the wrench. - const { layout, isLayoutLoading } = useDocumentsLayout(moduleEnabled); - - // v1.30.1 — pull-to-refresh parity with labs/checkups/mood/ - // measurements/medications. The vault is one of the sharpest PWA-resume- - // staleness surfaces named in the audit: a document indexed server-side - // while the tab was backgrounded (auto-index / AI summary) otherwise - // stays invisible until the bounded processing poll above happens to - // catch it or the user navigates away and back. - const refreshDocuments = useCallback( - () => Promise.all([list.refetch(), usage.refetch()]), - [list, usage], - ); - const pull = usePullToRefresh({ onRefresh: refreshDocuments }); - - const upload = useDocumentUpload( - usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined, - ); - const uploadInputRef = useRef(null); - - // One intake path for every source (picker, zone drop, page-wide drop, - // clipboard paste): pre-links to the active episode filter and feeds the - // sr-only live region — the drop overlay itself is decorative. - const [uploadAnnouncement, setUploadAnnouncement] = useState(""); - const enqueueRef = useRef(upload.enqueue); - useEffect(() => { - enqueueRef.current = upload.enqueue; - }, [upload.enqueue]); - const episodeIdFilter = filters.episodeId; - const encounterIdFilter = filters.encounterId; - const enqueueFiles = useCallback( - (files: File[]) => { - if (files.length === 0) return; - enqueueRef.current(files, { - episodeId: episodeIdFilter, - encounterId: encounterIdFilter, - }); - setUploadAnnouncement( - t("documents.upload.queuedAnnouncement", { count: files.length }), - ); - }, - [episodeIdFilter, encounterIdFilter, t], - ); - const { dropActive } = usePageFileDrop( - canManageDocuments ? enqueueFiles : undefined, - ); - - const episodes = useIllnessEpisodes( - true, - !inSharedRecord || sections === null || sections.includes("illness"), - ); - - // Content-search coverage: fire the corpus backfill when indexing is - // available and some documents are not yet searchable. The list search - // already unions content matches server-side — this only surfaces the hint - // and the "index all" affordance from the usage gauge. - const reindexAll = useReindexAll(); - const contentIndex = usage.data?.contentIndex; - const canIndexContent = contentIndex?.enabled ?? false; - const showIndexAll = - canManageDocuments && - canIndexContent && - contentIndex !== undefined && - contentIndex.totalCount > 0 && - contentIndex.indexedCount < contentIndex.totalCount; - const handleIndexAll = useCallback(() => { - reindexAll.mutate(undefined, { - onSuccess: (result) => - toast.success( - result.enqueued > 0 - ? t("documents.contentIndex.indexAllQueued", { - count: result.enqueued, - }) - : t("documents.contentIndex.indexAllNothing"), - ), - onError: (failure) => - showErrorToast(t("documents.contentIndex.indexAllError"), { - cause: failure, - }), - }); - }, [reindexAll, t]); - - // Condition chips: every episode carrying at least one live document - // link, served by the usage endpoint (NOT derived from the loaded corpus - // — an old linked document pages deep must still surface its chip), plus - // the actively filtered episode (a deep link must always show its own - // chip, even when its last link was just removed). - const conditionChips = useMemo(() => { - const byId = new Map(); - for (const link of usage.data?.linkedEpisodes ?? []) { - byId.set(link.episodeId, link.name); - } - if (filters.episodeId && !byId.has(filters.episodeId)) { - const episode = episodes.data?.find((e) => e.id === filters.episodeId); - byId.set(filters.episodeId, episode?.label ?? filters.episodeId); - } - return [...byId.entries()] - .map(([episodeId, name]) => ({ episodeId, name })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]); - - // The visits the bulk bar can file a selection against: the account's own, - // read through the same list the checkups page shows. Labelled here rather - // than in the bar so the label uses the reader's locale for the kind and the - // reader's timezone for the date — both presentation, both this side's job. - const visits = useEncounters( - !inSharedRecord || sections === null || sections.includes("profile"), - ); - const bulkEncounterOptions = useMemo( - () => - [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])] - .slice(0, BULK_VISIT_OPTIONS) - .map((visit) => ({ - id: visit.id, - label: `${ - visit.practitioner?.name ?? - encounterKindText(t, visit.kind as EncounterKind) - }, ${format.date(visit.occurredAt)}`, - })), - [visits.data, t, format], - ); - - // Procedure choices: every procedure carrying at least one live document - // link, served by the usage endpoint for the same reason as the condition - // chips, plus the actively filtered visit when it is a procedure (see - // `buildProcedureChoices`). - const procedureChips = useMemo(() => { - const activeVisit = filters.encounterId - ? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find( - (v) => v.id === filters.encounterId && v.kind === "PROCEDURE", - ) - : undefined; - return buildProcedureChoices( - usage.data?.linkedProcedures ?? [], - activeVisit - ? { - encounterId: activeVisit.id, - occurredAt: activeVisit.occurredAt, - reason: activeVisit.reason, - bodySite: activeVisit.bodySite, - laterality: activeVisit.laterality, - practitionerName: activeVisit.practitioner?.name ?? null, - } - : null, - { - kindName: encounterKindText(t, "PROCEDURE"), - siteText: (site, laterality) => bodySiteText(t, site, laterality), - formatDate: (iso) => format.date(iso), - }, - ); - }, [ - usage.data?.linkedProcedures, - filters.encounterId, - visits.data, - t, - format, - ]); - - // Year segmenter: years present in the loaded corpus (+ the active year). - const years = useMemo(() => { - const set = new Set(); - for (const doc of documents) { - set.add(Number(documentDateKey(doc, timezone).slice(0, 4))); - } - if (filters.year !== undefined) set.add(filters.year); - return [...set].sort((a, b) => b - a); - }, [documents, filters.year, timezone]); - - // ── Selection + bulk actions ────────────────────────────────────────── - const [selectedIds, setSelectedIds] = useState>( - new Set(), - ); - // Anchor for shift-click ranges: the last plainly-toggled id. - const rangeAnchorRef = useRef(null); - const toggleSelected = useCallback( - (id: string, range?: boolean) => { - setSelectedIds((prev) => { - if (range) { - return expandRangeSelection( - documents.map((d) => d.id), - prev, - rangeAnchorRef.current, - id, - ); - } - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - if (!range) rangeAnchorRef.current = id; - }, - [documents], - ); - const clearSelection = useCallback(() => { - setSelectedIds(new Set()); - rangeAnchorRef.current = null; - }, []); - - // Escape clears the selection — unless a dialog/popover owns the key. - useEffect(() => { - if (selectedIds.size === 0) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - const target = event.target as HTMLElement | null; - if (target?.closest('[role="dialog"],[role="menu"]')) return; - clearSelection(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [selectedIds.size, clearSelection]); - - /** - * One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results - * are merged so a partial failure in any chunk surfaces once. The - * endpoint is no-op-success on already-in-state rows, so an undo toast - * firing twice is safe by contract. - */ - const bulk = useMutation({ - mutationFn: async (input: { - ids: string[]; - action: DocumentBulkAction; - kind?: InboundDocumentKindValue; - episodeId?: string; - }) => { - const results: DocumentBulkResultDto[] = []; - for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) { - const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS); - const page = await apiPost<{ results: DocumentBulkResultDto[] }>( - "/api/documents/inbound/bulk", - { - ids: chunk, - action: input.action, - ...(input.kind !== undefined && { kind: input.kind }), - ...(input.episodeId !== undefined && { - episodeId: input.episodeId, - }), - }, - ); - results.push(...page.results); - } - return results; - }, - onSettled: () => { - void invalidateKeys(queryClient, [queryKeys.documents()]); - }, - }); - - const reportBulkOutcome = useCallback( - (results: DocumentBulkResultDto[], successMessage: string) => { - const failed = results.filter((r) => !r.ok).length; - if (failed > 0) { - showErrorToast( - t("documents.bulk.partialFailure", { - failed, - total: results.length, - }), - ); - } else { - toast.success(successMessage); - } - }, - [t], - ); - - const runBulk = useCallback( - ( - action: Exclude, - extra: { - kind?: InboundDocumentKindValue; - episodeId?: string; - encounterId?: string; - }, - ) => { - const ids = [...selectedIds]; - bulk.mutate( - { ids, action, ...extra }, - { - onSuccess: (results) => { - reportBulkOutcome( - results, - t("documents.bulk.updated", { - count: results.filter((r) => r.ok).length, - }), - ); - clearSelection(); - }, - onError: (failure) => - showErrorToast(t("documents.bulk.failed"), { cause: failure }), - }, - ); - }, - [selectedIds, bulk, reportBulkOutcome, clearSelection, t], - ); - - const restoreBulk = useCallback( - (ids: string[]) => { - bulk.mutate( - { ids, action: "restore" }, - { - onSuccess: (results) => - reportBulkOutcome( - results, - t("documents.bulk.restored", { - count: results.filter((r) => r.ok).length, - }), - ), - onError: (failure) => - showErrorToast(t("documents.toast.restoreFailed"), { - cause: failure, - }), - }, - ); - }, - [bulk, reportBulkOutcome, t], - ); - - const deleteBulk = useCallback( - (ids: string[]) => { - bulk.mutate( - { ids, action: "delete" }, - { - onSuccess: (results) => { - const okIds = results.filter((r) => r.ok).map((r) => r.id); - const failed = results.length - okIds.length; - if (failed > 0) { - showErrorToast( - t("documents.bulk.partialFailure", { - failed, - total: results.length, - }), - ); - } - if (okIds.length > 0) { - // ONE aggregate undo for the whole batch (bulk restore). - toast.success( - t("documents.bulk.deleted", { count: okIds.length }), - { - action: { - label: t("common.undo"), - onClick: () => restoreBulk(okIds), - }, - }, - ); - } - clearSelection(); - }, - onError: (failure) => - showErrorToast(t("documents.bulk.failed"), { cause: failure }), - }, - ); - }, - [bulk, clearSelection, restoreBulk, t], - ); - - // ── Delete confirmation ─────────────────────────────────────────────── - // Every way of deleting from the vault page — the bulk bar's Delete and a - // card's Delete key — comes through here and asks first. Nothing on this - // page calls `deleteBulk` directly. - const [pendingDeleteIds, setPendingDeleteIds] = useState( - null, - ); - const requestDelete = useCallback((ids: string[]) => { - if (ids.length > 0) setPendingDeleteIds(ids); - }, []); - const confirmDelete = useCallback(() => { - if (pendingDeleteIds) deleteBulk(pendingDeleteIds); - setPendingDeleteIds(null); - }, [pendingDeleteIds, deleteBulk]); - - // ── Bulk share ──────────────────────────────────────────────────────── - // ONE documents-only link for the whole selection (the share model carries - // up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded - // corpus — a selected id is always a rendered row. Over the cap we surface a - // hint and refuse rather than silently dropping docs from the link. - const [bulkShareOpen, setBulkShareOpen] = useState(false); - const [bulkShareDocs, setBulkShareDocs] = useState< - { id: string; title: string }[] - >([]); - const openBulkShare = useCallback(() => { - const resolved = resolveBulkShareDocuments( - documents, - selectedIds, - t("documents.card.untitled"), - ); - if (resolved.overCap) { - showErrorToast( - t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), - ); - return; - } - if (resolved.documents.length === 0) return; - setBulkShareDocs(resolved.documents); - setBulkShareOpen(true); - }, [documents, selectedIds, t]); - - // ── Detail sheet ────────────────────────────────────────────────────── - const [detailId, setDetailId] = useState(null); - const [detailOpen, setDetailOpen] = useState(false); - const handedOffDocumentRef = useRef(null); - - // `?doc=` read live (`@/lib/url-state`): Next 16.4 adopts a pushState a - // render after the write, and a Back pressed in between left the router's - // copy reading "no document" on both sides, so the sheet stayed open. - const liveParams = useUrlSearch(); - const rawDocParam = liveParams.get("doc"); - const docParam = - rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam) - ? rawDocParam - : null; - const [observedDocParam, setObservedDocParam] = useState(null); - - // The query is the navigation owner: direct links, browser back, and browser - // forward all drive the same detail selection without remounting the route. - // Wait for auth so a deep link cannot be consumed before the module gate is - // known, and never interpolate an unvalidated id into the detail API path. - if (!authLoading && moduleEnabled && rawDocParam !== observedDocParam) { - setObservedDocParam(rawDocParam); - setDetailId(docParam); - setDetailOpen(docParam !== null); - } - - const openDetail = useCallback( - (id: string) => { - const href = documentSelectionHref(pathname, currentUrlSearch(), id); - if (href === null) return; - handedOffDocumentRef.current = null; - setDetailId(id); - setDetailOpen(true); - writeUrlState((params) => params.set("doc", id), { - mode: "push", - state: documentSelectionHistoryState(id), - }); - }, - [pathname], - ); - - const handleDetailOpenChange = useCallback((open: boolean) => { - setDetailOpen(open); - }, []); - - const coachOwnsDocument = - docParam !== null && - coachLaunch?.open === true && - coachLaunch.documentId === docParam; - - useEffect(() => { - if (authLoading || !moduleEnabled) return; - if (docParam === null) { - handedOffDocumentRef.current = null; - return; - } - if (detailOpen) return; - if (coachOwnsDocument) { - // "Ask Coach" intentionally closes the sheet while the drawer owns the - // same document. Keep the return URL intact so maximize → browser-back - // can reconstruct the sheet from `?doc=`. - handedOffDocumentRef.current = docParam; - return; - } - // Read before the call below reassigns the ref: the same inputs the close - // itself decides on. - const retainedAsReturnUrl = coachHandoffRetainsSelection( - docParam, - handedOffDocumentRef.current, - coachLaunch?.closeIntent, - ); - - handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff( - urlStateHistory, - pathname, - currentUrlSearch(), - docParam, - handedOffDocumentRef.current, - coachLaunch?.closeIntent, - ); - - // Nothing was closed: the selection is the way back from `/coach` and is - // meant to stay. Checking for it here would delete it. - if (retainedAsReturnUrl) return; - - // Consuming the pushed entry is a REQUEST to the browser, not a guarantee. - // - // `history.back()` is queued against the session history, and a traversal - // asked for while another is still settling can be dropped — closing the - // sheet immediately after pressing the browser's Forward button is exactly - // that shape. Nothing raises when it happens: the sheet is closed, React - // is consistent, and the URL still names the document. A reload or a - // shared link then re-opens the sheet the reader just closed. - // - // So the request is checked rather than trusted. If the selection is still - // in the address bar shortly afterwards, it is removed the synchronous - // way. The guard is the document id, so a traversal that landed on some - // other selection is left alone, and the cleanup below cancels the check - // the moment the sheet re-opens or the parameter changes — which is what - // makes re-opening within the window safe. - const verify = window.setTimeout(() => { - if (!documentSelectionSurvivedClose(currentUrlSearch(), docParam)) { - return; - } - writeUrlState((params) => params.delete("doc")); - }, CLOSE_TRAVERSAL_GRACE_MS); - return () => window.clearTimeout(verify); - }, [ - authLoading, - coachOwnsDocument, - coachLaunch?.closeIntent, - detailOpen, - docParam, + const { + filters, + searchDraft, + setSearchDraft, + searchInputRef, + toggleKind, + toggleEpisode, + toggleEncounter, + toggleYear, + clearFilters, + } = useVaultFilters(); + const { + usage, + list, + documents, + layout, + isLayoutLoading, + pull, + upload, + uploadInputRef, + uploadAnnouncement, + enqueueFiles, + dropActive, + episodes, + reindexAll, + showIndexAll, + handleIndexAll, + conditionChips, + bulkEncounterOptions, + procedureChips, + years, + } = useVaultLibrary({ + filters, moduleEnabled, - pathname, - ]); - - // Hover/focus intent prefetches the detail METADATA (never the blob — - // the blob fetch starts when the sheet mounts its preview element). - const prefetchDetail = useCallback( - (id: string) => { - void queryClient.prefetchQuery({ - queryKey: queryKeys.inboundDocument(id), - queryFn: () => - apiGet(`/api/documents/inbound/${id}`), - staleTime: 30_000, - }); - }, - [queryClient], - ); + canManageDocuments, + inSharedRecord, + sections, + timezone, + }); + const { + selectedIds, + toggleSelected, + clearSelection, + bulk, + runBulk, + pendingDeleteIds, + setPendingDeleteIds, + requestDelete, + confirmDelete, + bulkShareOpen, + setBulkShareOpen, + bulkShareDocs, + openBulkShare, + } = useVaultSelection(documents); + const { + detailId, + detailOpen, + openDetail, + handleDetailOpenChange, + prefetchDetail, + } = useVaultDetail({ authLoading, moduleEnabled, pathname }); if (authLoading || !isAuthenticated || !moduleEnabled) { return ( @@ -978,45 +164,12 @@ export function DocumentsView() { description={t("documents.subtitle")} actions={ canManageDocuments || !inSharedRecord ? ( -
- {/* The wrench left of the primary action, as on the illness - journal: it opens this vault's display settings. `/settings` - is the caller's own, so it never shows inside somebody - else's record. */} - {!inSharedRecord ? ( - - ) : null} - {canManageDocuments ? ( - <> - {/* Renders only with a connected Paperless-ngx or Papra. */} - - {/* An empty vault's empty state carries the upload. */} - {showEmpty && !isFiltered ? null : ( - - )} - - ) : null} -
+ ) : null } /> @@ -1194,27 +347,7 @@ export function DocumentsView() { documents={bulkShareDocs} /> - {/* Page-wide drop overlay — pure decoration (aria-hidden); the intake - itself announces through the live region below. */} - {dropActive ? ( -
-
-
- -

- {t("documents.dropOverlay.title")} -

-

- {t("documents.dropOverlay.hint")} -

-
-
-
- ) : null} + {dropActive ? : null}

{uploadAnnouncement}

diff --git a/src/components/documents/vault/selection-history.ts b/src/components/documents/vault/selection-history.ts new file mode 100644 index 000000000..116f1a539 --- /dev/null +++ b/src/components/documents/vault/selection-history.ts @@ -0,0 +1,148 @@ +import type { CoachCloseIntent } from "@/lib/insights/coach-launch-context"; + +/** + * The open document lives in the URL as `?doc=`, pushed as its own + * history entry when a card opens it, so Back closes the sheet and a link + * reopens it. These helpers own that entry: building the URL, recognising + * the entry a card pushed, consuming it on close, and the one close (the + * Coach handoff) that keeps it as the way back. + */ +// Strict end-of-input: `$` also accepts a trailing newline in JavaScript. +export const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/; +const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection"; + +export function documentSelectionHistoryState( + documentId: string, +): Record { + // Pass only application state here. Next's patched pushState copies its + // private router fields onto this object; copying history.state ourselves + // would include `__NA` and make that patch treat this as an internal write, + // skipping useSearchParams synchronization. + return { [DOCUMENT_SELECTION_HISTORY_KEY]: documentId }; +} + +export function documentSelectionHref( + pathname: string, + currentSearch: string, + documentId: string, +): string | null { + if (!DOCUMENT_QUERY_ID_PATTERN.test(documentId)) return null; + const next = new URLSearchParams(currentSearch); + next.set("doc", documentId); + const search = next.toString(); + return search ? `${pathname}?${search}` : pathname; +} + +export function withoutDocumentSelectionHref( + pathname: string, + currentSearch: string, +): string { + const next = new URLSearchParams(currentSearch); + next.delete("doc"); + const search = next.toString(); + return search ? `${pathname}?${search}` : pathname; +} + +/** + * How long to wait before checking that `history.back()` actually happened. + * + * Long enough for a traversal to commit on a loaded machine, short enough that + * a reader who closed the sheet does not see the selection linger in the + * address bar. The window is bounded on the other side too: the effect that + * arms this cancels it as soon as the sheet re-opens. + */ +export const CLOSE_TRAVERSAL_GRACE_MS = 250; + +/** + * The selection this close was meant to consume is still in the address bar. + * + * Matched on the id rather than on the presence of `doc`, so a traversal that + * landed somewhere carrying a DIFFERENT selection is not mistaken for a + * dropped one and stripped. + */ +export function documentSelectionSurvivedClose( + search: string, + documentId: string, +): boolean { + return new URLSearchParams(search).get("doc") === documentId; +} + +export function closeDocumentSelectionHistoryEntry( + history: { + readonly state: unknown; + back: () => void; + replaceState: ( + data: unknown, + unused: string, + url?: string | URL | null, + ) => void; + }, + pathname: string, + currentSearch: string, + documentId: string, +): void { + const ownsCurrentEntry = + history.state !== null && + typeof history.state === "object" && + (history.state as Record)[ + DOCUMENT_SELECTION_HISTORY_KEY + ] === documentId; + if (ownsCurrentEntry) { + // Card selection owns the current pushed entry. Returning to its base + // consumes it, so the user's next Back reaches the prior distinct page. + history.back(); + return; + } + + // A deep link did not create a disposable entry on this page. Keep the user + // here and remove only the selection from the current URL. + history.replaceState( + null, + "", + withoutDocumentSelectionHref(pathname, currentSearch), + ); +} + +/** + * The selection is being KEPT on purpose, as the way back. + * + * Maximizing the Coach drawer hands the conversation to `/coach` and carries + * `?doc=` with it, so browser-Back can reconstruct the sheet. That is the one + * close-shaped transition that must leave the parameter exactly where it is. + * + * Named, and used by both the close and the check that follows it, because + * those two disagreeing is a bug with no symptom until it has one: the check + * would strip the return URL a few hundred milliseconds after the close + * deliberately preserved it, and whether it won that race was a question about + * how fast the machine navigated. + */ +export function coachHandoffRetainsSelection( + documentId: string, + handedOffDocumentId: string | null, + closeIntent: CoachCloseIntent | null | undefined, +): boolean { + return handedOffDocumentId === documentId && closeIntent === "navigate"; +} + +export function closeDocumentSelectionAfterCoachHandoff( + history: Parameters[0], + pathname: string, + currentSearch: string, + documentId: string, + handedOffDocumentId: string | null, + closeIntent: CoachCloseIntent | null | undefined, +): string | null { + if ( + coachHandoffRetainsSelection(documentId, handedOffDocumentId, closeIntent) + ) { + return handedOffDocumentId; + } + + closeDocumentSelectionHistoryEntry( + history, + pathname, + currentSearch, + documentId, + ); + return handedOffDocumentId === documentId ? null : handedOffDocumentId; +} diff --git a/src/components/documents/vault/use-vault-bulk.ts b/src/components/documents/vault/use-vault-bulk.ts new file mode 100644 index 000000000..875551303 --- /dev/null +++ b/src/components/documents/vault/use-vault-bulk.ts @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { apiPost } from "@/lib/api/api-fetch"; +import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import type { + DocumentBulkAction, + DocumentBulkResultDto, + InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; + +/** + * The bulk actions on a selection: re-file and link (one outcome toast), and + * delete with one undo for the whole batch. Deleting is only ever reached + * through the page's confirmation. + */ +export function useVaultBulk({ + selectedIds, + clearSelection, +}: { + selectedIds: ReadonlySet; + clearSelection: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + /** + * One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results + * are merged so a partial failure in any chunk surfaces once. The + * endpoint is no-op-success on already-in-state rows, so an undo toast + * firing twice is safe by contract. + */ + const bulk = useMutation({ + mutationFn: async (input: { + ids: string[]; + action: DocumentBulkAction; + kind?: InboundDocumentKindValue; + episodeId?: string; + }) => { + const results: DocumentBulkResultDto[] = []; + for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) { + const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS); + const page = await apiPost<{ results: DocumentBulkResultDto[] }>( + "/api/documents/inbound/bulk", + { + ids: chunk, + action: input.action, + ...(input.kind !== undefined && { kind: input.kind }), + ...(input.episodeId !== undefined && { + episodeId: input.episodeId, + }), + }, + ); + results.push(...page.results); + } + return results; + }, + onSettled: () => { + void invalidateKeys(queryClient, [queryKeys.documents()]); + }, + }); + + const reportBulkOutcome = useCallback( + (results: DocumentBulkResultDto[], successMessage: string) => { + const failed = results.filter((r) => !r.ok).length; + if (failed > 0) { + showErrorToast( + t("documents.bulk.partialFailure", { + failed, + total: results.length, + }), + ); + } else { + toast.success(successMessage); + } + }, + [t], + ); + + const runBulk = useCallback( + ( + action: Exclude, + extra: { + kind?: InboundDocumentKindValue; + episodeId?: string; + encounterId?: string; + }, + ) => { + const ids = [...selectedIds]; + bulk.mutate( + { ids, action, ...extra }, + { + onSuccess: (results) => { + reportBulkOutcome( + results, + t("documents.bulk.updated", { + count: results.filter((r) => r.ok).length, + }), + ); + clearSelection(); + }, + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), + }, + ); + }, + [selectedIds, bulk, reportBulkOutcome, clearSelection, t], + ); + + const restoreBulk = useCallback( + (ids: string[]) => { + bulk.mutate( + { ids, action: "restore" }, + { + onSuccess: (results) => + reportBulkOutcome( + results, + t("documents.bulk.restored", { + count: results.filter((r) => r.ok).length, + }), + ), + onError: (failure) => + showErrorToast(t("documents.toast.restoreFailed"), { + cause: failure, + }), + }, + ); + }, + [bulk, reportBulkOutcome, t], + ); + + const deleteBulk = useCallback( + (ids: string[]) => { + bulk.mutate( + { ids, action: "delete" }, + { + onSuccess: (results) => { + const okIds = results.filter((r) => r.ok).map((r) => r.id); + const failed = results.length - okIds.length; + if (failed > 0) { + showErrorToast( + t("documents.bulk.partialFailure", { + failed, + total: results.length, + }), + ); + } + if (okIds.length > 0) { + // ONE aggregate undo for the whole batch (bulk restore). + toast.success( + t("documents.bulk.deleted", { count: okIds.length }), + { + action: { + label: t("common.undo"), + onClick: () => restoreBulk(okIds), + }, + }, + ); + } + clearSelection(); + }, + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), + }, + ); + }, + [bulk, clearSelection, restoreBulk, t], + ); + + return { bulk, runBulk, deleteBulk }; +} diff --git a/src/components/documents/vault/use-vault-detail.ts b/src/components/documents/vault/use-vault-detail.ts new file mode 100644 index 000000000..7c5854a2f --- /dev/null +++ b/src/components/documents/vault/use-vault-detail.ts @@ -0,0 +1,180 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { queryKeys } from "@/lib/query-keys"; +import { + currentUrlSearch, + urlStateHistory, + useUrlSearch, + writeUrlState, +} from "@/lib/url-state"; +import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; + +import { + CLOSE_TRAVERSAL_GRACE_MS, + closeDocumentSelectionAfterCoachHandoff, + coachHandoffRetainsSelection, + DOCUMENT_QUERY_ID_PATTERN, + documentSelectionHistoryState, + documentSelectionHref, + documentSelectionSurvivedClose, +} from "./selection-history"; + +/** + * The detail sheet, driven by `?doc=`: a card pushes the selection, Back and + * Forward and direct links drive the same state without remounting the + * route, and a close consumes the pushed entry (and checks that it did). + */ +export function useVaultDetail({ + authLoading, + moduleEnabled, + pathname, +}: { + authLoading: boolean; + moduleEnabled: boolean; + pathname: string; +}) { + const queryClient = useQueryClient(); + const coachLaunch = useCoachLaunch(); + const [detailId, setDetailId] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const handedOffDocumentRef = useRef(null); + + // `?doc=` read live (`@/lib/url-state`): Next 16.4 adopts a pushState a + // render after the write, and a Back pressed in between left the router's + // copy reading "no document" on both sides, so the sheet stayed open. + const liveParams = useUrlSearch(); + const rawDocParam = liveParams.get("doc"); + const docParam = + rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam) + ? rawDocParam + : null; + const [observedDocParam, setObservedDocParam] = useState(null); + + // The query is the navigation owner: direct links, browser back, and browser + // forward all drive the same detail selection without remounting the route. + // Wait for auth so a deep link cannot be consumed before the module gate is + // known, and never interpolate an unvalidated id into the detail API path. + if (!authLoading && moduleEnabled && rawDocParam !== observedDocParam) { + setObservedDocParam(rawDocParam); + setDetailId(docParam); + setDetailOpen(docParam !== null); + } + + const openDetail = useCallback( + (id: string) => { + const href = documentSelectionHref(pathname, currentUrlSearch(), id); + if (href === null) return; + handedOffDocumentRef.current = null; + setDetailId(id); + setDetailOpen(true); + writeUrlState((params) => params.set("doc", id), { + mode: "push", + state: documentSelectionHistoryState(id), + }); + }, + [pathname], + ); + + const handleDetailOpenChange = useCallback((open: boolean) => { + setDetailOpen(open); + }, []); + + const coachOwnsDocument = + docParam !== null && + coachLaunch?.open === true && + coachLaunch.documentId === docParam; + + useEffect(() => { + if (authLoading || !moduleEnabled) return; + if (docParam === null) { + handedOffDocumentRef.current = null; + return; + } + if (detailOpen) return; + if (coachOwnsDocument) { + // "Ask Coach" intentionally closes the sheet while the drawer owns the + // same document. Keep the return URL intact so maximize → browser-back + // can reconstruct the sheet from `?doc=`. + handedOffDocumentRef.current = docParam; + return; + } + // Read before the call below reassigns the ref: the same inputs the close + // itself decides on. + const retainedAsReturnUrl = coachHandoffRetainsSelection( + docParam, + handedOffDocumentRef.current, + coachLaunch?.closeIntent, + ); + + handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff( + urlStateHistory, + pathname, + currentUrlSearch(), + docParam, + handedOffDocumentRef.current, + coachLaunch?.closeIntent, + ); + + // Nothing was closed: the selection is the way back from `/coach` and is + // meant to stay. Checking for it here would delete it. + if (retainedAsReturnUrl) return; + + // Consuming the pushed entry is a REQUEST to the browser, not a guarantee. + // + // `history.back()` is queued against the session history, and a traversal + // asked for while another is still settling can be dropped — closing the + // sheet immediately after pressing the browser's Forward button is exactly + // that shape. Nothing raises when it happens: the sheet is closed, React + // is consistent, and the URL still names the document. A reload or a + // shared link then re-opens the sheet the reader just closed. + // + // So the request is checked rather than trusted. If the selection is still + // in the address bar shortly afterwards, it is removed the synchronous + // way. The guard is the document id, so a traversal that landed on some + // other selection is left alone, and the cleanup below cancels the check + // the moment the sheet re-opens or the parameter changes — which is what + // makes re-opening within the window safe. + const verify = window.setTimeout(() => { + if (!documentSelectionSurvivedClose(currentUrlSearch(), docParam)) { + return; + } + writeUrlState((params) => params.delete("doc")); + }, CLOSE_TRAVERSAL_GRACE_MS); + return () => window.clearTimeout(verify); + }, [ + authLoading, + coachOwnsDocument, + coachLaunch?.closeIntent, + detailOpen, + docParam, + moduleEnabled, + pathname, + ]); + + // Hover/focus intent prefetches the detail METADATA (never the blob — + // the blob fetch starts when the sheet mounts its preview element). + const prefetchDetail = useCallback( + (id: string) => { + void queryClient.prefetchQuery({ + queryKey: queryKeys.inboundDocument(id), + queryFn: () => + apiGet(`/api/documents/inbound/${id}`), + staleTime: 30_000, + }); + }, + [queryClient], + ); + + return { + detailId, + detailOpen, + openDetail, + handleDetailOpenChange, + prefetchDetail, + }; +} diff --git a/src/components/documents/vault/use-vault-filter-choices.ts b/src/components/documents/vault/use-vault-filter-choices.ts new file mode 100644 index 000000000..3c40963ec --- /dev/null +++ b/src/components/documents/vault/use-vault-filter-choices.ts @@ -0,0 +1,138 @@ +"use client"; + +import { useMemo } from "react"; + +import { + bodySiteText, + encounterKindText, +} from "@/components/encounters/encounter-labels"; +import type { EncounterKind } from "@/generated/prisma/client"; +import { useEncounters } from "@/hooks/use-encounters"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + DocumentUsageDto, + InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; + +import type { ConditionChip } from "../document-filter-bar"; +import { + buildProcedureChoices, + documentDateKey, + type ProcedureFilterChoice, +} from "../vault-utils"; + +import type { VaultFilters } from "./use-vault-filters"; + +/** + * How many visits the bulk bar offers. A menu is a picker, not a history: past + * the first screenful a person scrolls rather than reads, and filing against a + * visit older than this belongs on the visit's own sheet. + */ +const BULK_VISIT_OPTIONS = 12; + +/** + * The choices the filter bar and the bulk bar offer: conditions and + * procedures with linked documents, the visits a selection can be filed + * against, and the years present. + */ +export function useVaultFilterChoices({ + filters, + usage, + episodes, + documents, + timezone, + inSharedRecord, + sections, +}: { + filters: VaultFilters["filters"]; + usage: DocumentUsageDto | undefined; + episodes: Array<{ id: string; label: string }> | undefined; + documents: InboundDocumentDto[]; + timezone: string; + inSharedRecord: boolean; + sections: readonly string[] | null; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + // Condition chips: every episode carrying at least one live document + // link, served by the usage endpoint (NOT derived from the loaded corpus + // — an old linked document pages deep must still surface its chip), plus + // the actively filtered episode (a deep link must always show its own + // chip, even when its last link was just removed). + const conditionChips = useMemo(() => { + const byId = new Map(); + for (const link of usage?.linkedEpisodes ?? []) { + byId.set(link.episodeId, link.name); + } + if (filters.episodeId && !byId.has(filters.episodeId)) { + const episode = episodes?.find((e) => e.id === filters.episodeId); + byId.set(filters.episodeId, episode?.label ?? filters.episodeId); + } + return [...byId.entries()] + .map(([episodeId, name]) => ({ episodeId, name })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [usage?.linkedEpisodes, filters.episodeId, episodes]); + + // The visits the bulk bar can file a selection against: the account's own, + // read through the same list the checkups page shows. Labelled here rather + // than in the bar so the label uses the reader's locale for the kind and the + // reader's timezone for the date — both presentation, both this side's job. + const visits = useEncounters( + !inSharedRecord || sections === null || sections.includes("profile"), + ); + const bulkEncounterOptions = useMemo( + () => + [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])] + .slice(0, BULK_VISIT_OPTIONS) + .map((visit) => ({ + id: visit.id, + label: `${ + visit.practitioner?.name ?? + encounterKindText(t, visit.kind as EncounterKind) + }, ${format.date(visit.occurredAt)}`, + })), + [visits.data, t, format], + ); + + // Procedure choices: every procedure carrying at least one live document + // link, served by the usage endpoint for the same reason as the condition + // chips, plus the actively filtered visit when it is a procedure (see + // `buildProcedureChoices`). + const procedureChips = useMemo(() => { + const activeVisit = filters.encounterId + ? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find( + (v) => v.id === filters.encounterId && v.kind === "PROCEDURE", + ) + : undefined; + return buildProcedureChoices( + usage?.linkedProcedures ?? [], + activeVisit + ? { + encounterId: activeVisit.id, + occurredAt: activeVisit.occurredAt, + reason: activeVisit.reason, + bodySite: activeVisit.bodySite, + laterality: activeVisit.laterality, + practitionerName: activeVisit.practitioner?.name ?? null, + } + : null, + { + kindName: encounterKindText(t, "PROCEDURE"), + siteText: (site, laterality) => bodySiteText(t, site, laterality), + formatDate: (iso) => format.date(iso), + }, + ); + }, [usage?.linkedProcedures, filters.encounterId, visits.data, t, format]); + + // Year segmenter: years present in the loaded corpus (+ the active year). + const years = useMemo(() => { + const set = new Set(); + for (const doc of documents) { + set.add(Number(documentDateKey(doc, timezone).slice(0, 4))); + } + if (filters.year !== undefined) set.add(filters.year); + return [...set].sort((a, b) => b - a); + }, [documents, filters.year, timezone]); + + return { conditionChips, bulkEncounterOptions, procedureChips, years }; +} diff --git a/src/components/documents/vault/use-vault-filters.ts b/src/components/documents/vault/use-vault-filters.ts new file mode 100644 index 000000000..ab1aa697f --- /dev/null +++ b/src/components/documents/vault/use-vault-filters.ts @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; + +import { parseVaultSearchParams, vaultFiltersToSearch } from "../vault-utils"; + +/** + * The vault's URL-owned filter state (`?q&kind&episode&encounter&year`), so + * every view is shareable, back-button-safe and deep-linkable. The search + * draft debounces into the URL and `/` focuses it from anywhere on the page. + */ +export function useVaultFilters() { + // The parse/serialise round trip stays in `vault-utils.ts`; the router + // plumbing lives in the shared hook (which the measurements and mood + // lists reuse — this surface is the pattern's origin). + const { filters, applyFilters } = useUrlFilterSync({ + parse: parseVaultSearchParams, + serialise: vaultFiltersToSearch, + }); + + // Search draft debounces into the URL (200 ms); an external URL change + // (back button, deep link) re-seeds the draft — render-phase derived-state + // adjustment, not an effect. + const [searchDraft, setSearchDraft] = useState(filters.q ?? ""); + const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? ""); + const searchInputRef = useRef(null); + if ((filters.q ?? "") !== lastUrlQ) { + setLastUrlQ(filters.q ?? ""); + setSearchDraft(filters.q ?? ""); + } + useEffect(() => { + const handle = setTimeout(() => { + const trimmed = searchDraft.trim(); + if (trimmed === (filters.q ?? "")) return; + // Live filters, not the rendered ones a "Clear filters" just removed. + applyFilters( + (current) => + trimmed === (current.q ?? "") + ? current + : { ...current, q: trimmed === "" ? undefined : trimmed }, + "replace", + ); + }, 200); + return () => clearTimeout(handle); + }, [searchDraft, filters, applyFilters]); + + // `/` focuses the search from anywhere on the page. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) + return; + const target = event.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + event.preventDefault(); + searchInputRef.current?.focus(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + // Every facet toggles against the live filters (`applyFilters` updater). + const toggleKind = (kind: InboundDocumentKindValue) => { + applyFilters((current) => { + const kinds = new Set(current.kinds ?? []); + if (kinds.has(kind)) kinds.delete(kind); + else kinds.add(kind); + return { + ...current, + kinds: kinds.size > 0 ? [...kinds].sort() : undefined, + }; + }, "push"); + }; + + const toggleEpisode = (episodeId: string) => { + applyFilters( + (current) => ({ + ...current, + episodeId: current.episodeId === episodeId ? undefined : episodeId, + }), + "push", + ); + }; + + const toggleEncounter = (encounterId: string) => { + applyFilters( + (current) => ({ + ...current, + encounterId: + current.encounterId === encounterId ? undefined : encounterId, + }), + "push", + ); + }; + + const toggleYear = (year: number) => { + applyFilters( + (current) => ({ + ...current, + year: current.year === year ? undefined : year, + }), + "push", + ); + }; + + const clearFilters = () => { + setSearchDraft(""); + applyFilters({}, "push"); + }; + + return { + filters, + searchDraft, + setSearchDraft, + searchInputRef, + toggleKind, + toggleEpisode, + toggleEncounter, + toggleYear, + clearFilters, + }; +} + +export type VaultFilters = ReturnType; diff --git a/src/components/documents/vault/use-vault-library.ts b/src/components/documents/vault/use-vault-library.ts new file mode 100644 index 000000000..bfa31c406 --- /dev/null +++ b/src/components/documents/vault/use-vault-library.ts @@ -0,0 +1,205 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useIllnessEpisodes } from "@/components/illness/use-illness"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { apiGet } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DocumentUsageDto, + InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; + +import { useReindexAll } from "../use-content-index"; +import { useDocumentUpload } from "../use-document-upload"; +import { usePageFileDrop } from "../use-page-file-drop"; +import { buildVaultListApiSearch, hasProcessingDocument } from "../vault-utils"; + +import type { VaultFilters } from "./use-vault-filters"; +import { useVaultFilterChoices } from "./use-vault-filter-choices"; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +/** + * Everything the vault shows: the usage gauge, the paged list for the active + * filters, the reader's layout, the upload queue and its intake paths, the + * corpus-index affordance, and the filter choices (conditions, procedures, + * visits for the bulk bar, years). + */ +export function useVaultLibrary({ + filters, + moduleEnabled, + canManageDocuments, + inSharedRecord, + sections, + timezone, +}: { + filters: VaultFilters["filters"]; + moduleEnabled: boolean; + canManageDocuments: boolean; + inSharedRecord: boolean; + sections: readonly string[] | null; + timezone: string; +}) { + const { t } = useTranslations(); + const usage = useQuery({ + queryKey: queryKeys.inboundDocumentUsage(), + enabled: moduleEnabled, + queryFn: () => apiGet("/api/documents/inbound/usage"), + }); + + const list = useInfiniteQuery({ + queryKey: queryKeys.inboundDocumentList(filters), + enabled: moduleEnabled, + queryFn: ({ pageParam }) => + apiGet( + `/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`, + ), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + // .x — the auto-index (+ optional AI summary) job that lands right + // after upload is fire-and-forget server-side; nothing pushes its + // completion to the client. Poll while any loaded document is still + // inside its recent-upload processing window (see `hasProcessingDocument` + // — bounded, so an old/permanently-unindexed document never keeps this + // polling forever) so the card's "Processing…" chip clears on its own + // once the index lands, without a manual refresh. + refetchInterval: (query) => { + const pages = query.state.data?.pages ?? []; + const docs = pages.flatMap((p) => p.documents); + return hasProcessingDocument(docs) ? 4000 : false; + }, + }); + + const documents = useMemo( + () => list.data?.pages.flatMap((p) => p.documents) ?? [], + [list.data], + ); + + // The reader's presentation (cards / list, stacked / flowing months), + // persisted per user and changed behind the wrench. + const { layout, isLayoutLoading } = useDocumentsLayout(moduleEnabled); + + // Pull-to-refresh parity with labs/checkups/mood/ + // measurements/medications. The vault is one of the sharpest PWA-resume- + // staleness surfaces named in the audit: a document indexed server-side + // while the tab was backgrounded (auto-index / AI summary) otherwise + // stays invisible until the bounded processing poll above happens to + // catch it or the user navigates away and back. + const refreshDocuments = useCallback( + () => Promise.all([list.refetch(), usage.refetch()]), + [list, usage], + ); + const pull = usePullToRefresh({ onRefresh: refreshDocuments }); + + const upload = useDocumentUpload( + usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined, + ); + const uploadInputRef = useRef(null); + + // One intake path for every source (picker, zone drop, page-wide drop, + // clipboard paste): pre-links to the active episode filter and feeds the + // sr-only live region — the drop overlay itself is decorative. + const [uploadAnnouncement, setUploadAnnouncement] = useState(""); + const enqueueRef = useRef(upload.enqueue); + useEffect(() => { + enqueueRef.current = upload.enqueue; + }, [upload.enqueue]); + const episodeIdFilter = filters.episodeId; + const encounterIdFilter = filters.encounterId; + const enqueueFiles = useCallback( + (files: File[]) => { + if (files.length === 0) return; + enqueueRef.current(files, { + episodeId: episodeIdFilter, + encounterId: encounterIdFilter, + }); + setUploadAnnouncement( + t("documents.upload.queuedAnnouncement", { count: files.length }), + ); + }, + [episodeIdFilter, encounterIdFilter, t], + ); + const { dropActive } = usePageFileDrop( + canManageDocuments ? enqueueFiles : undefined, + ); + + const episodes = useIllnessEpisodes( + true, + !inSharedRecord || sections === null || sections.includes("illness"), + ); + + // Content-search coverage: fire the corpus backfill when indexing is + // available and some documents are not yet searchable. The list search + // already unions content matches server-side — this only surfaces the hint + // and the "index all" affordance from the usage gauge. + const reindexAll = useReindexAll(); + const contentIndex = usage.data?.contentIndex; + const canIndexContent = contentIndex?.enabled ?? false; + const showIndexAll = + canManageDocuments && + canIndexContent && + contentIndex !== undefined && + contentIndex.totalCount > 0 && + contentIndex.indexedCount < contentIndex.totalCount; + const handleIndexAll = useCallback(() => { + reindexAll.mutate(undefined, { + onSuccess: (result) => + toast.success( + result.enqueued > 0 + ? t("documents.contentIndex.indexAllQueued", { + count: result.enqueued, + }) + : t("documents.contentIndex.indexAllNothing"), + ), + onError: (failure) => + showErrorToast(t("documents.contentIndex.indexAllError"), { + cause: failure, + }), + }); + }, [reindexAll, t]); + + const { conditionChips, bulkEncounterOptions, procedureChips, years } = + useVaultFilterChoices({ + filters, + usage: usage.data, + episodes: episodes.data, + documents, + timezone, + inSharedRecord, + sections, + }); + + return { + usage, + list, + documents, + layout, + isLayoutLoading, + pull, + upload, + uploadInputRef, + uploadAnnouncement, + enqueueFiles, + dropActive, + episodes, + reindexAll, + showIndexAll, + handleIndexAll, + conditionChips, + bulkEncounterOptions, + procedureChips, + years, + }; +} + +export type VaultLibrary = ReturnType; diff --git a/src/components/documents/vault/use-vault-selection.ts b/src/components/documents/vault/use-vault-selection.ts new file mode 100644 index 000000000..c528ed7f7 --- /dev/null +++ b/src/components/documents/vault/use-vault-selection.ts @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +import { + expandRangeSelection, + resolveBulkShareDocuments, + SHARE_LINK_MAX_DOCUMENTS, +} from "../vault-utils"; + +import { useVaultBulk } from "./use-vault-bulk"; + +/** + * The selection (with shift-click ranges, cleared by Escape unless a dialog + * or menu owns the key) and what can be done with it: re-file, link, share + * as one link, and delete, which always asks first and offers one undo for + * the whole batch. + */ +export function useVaultSelection(documents: InboundDocumentDto[]) { + const { t } = useTranslations(); + const [selectedIds, setSelectedIds] = useState>( + new Set(), + ); + // Anchor for shift-click ranges: the last plainly-toggled id. + const rangeAnchorRef = useRef(null); + const toggleSelected = useCallback( + (id: string, range?: boolean) => { + setSelectedIds((prev) => { + if (range) { + return expandRangeSelection( + documents.map((d) => d.id), + prev, + rangeAnchorRef.current, + id, + ); + } + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + if (!range) rangeAnchorRef.current = id; + }, + [documents], + ); + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + rangeAnchorRef.current = null; + }, []); + + // Escape clears the selection — unless a dialog/popover owns the key. + useEffect(() => { + if (selectedIds.size === 0) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + const target = event.target as HTMLElement | null; + if (target?.closest('[role="dialog"],[role="menu"]')) return; + clearSelection(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selectedIds.size, clearSelection]); + + const { bulk, runBulk, deleteBulk } = useVaultBulk({ + selectedIds, + clearSelection, + }); + + // ── Delete confirmation ─────────────────────────────────────────────── + // Every way of deleting from the vault page — the bulk bar's Delete and a + // card's Delete key — comes through here and asks first. Nothing on this + // page calls `deleteBulk` directly. + const [pendingDeleteIds, setPendingDeleteIds] = useState( + null, + ); + const requestDelete = useCallback((ids: string[]) => { + if (ids.length > 0) setPendingDeleteIds(ids); + }, []); + const confirmDelete = useCallback(() => { + if (pendingDeleteIds) deleteBulk(pendingDeleteIds); + setPendingDeleteIds(null); + }, [pendingDeleteIds, deleteBulk]); + + // ── Bulk share ──────────────────────────────────────────────────────── + // ONE documents-only link for the whole selection (the share model carries + // up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded + // corpus — a selected id is always a rendered row. Over the cap we surface a + // hint and refuse rather than silently dropping docs from the link. + const [bulkShareOpen, setBulkShareOpen] = useState(false); + const [bulkShareDocs, setBulkShareDocs] = useState< + { id: string; title: string }[] + >([]); + const openBulkShare = useCallback(() => { + const resolved = resolveBulkShareDocuments( + documents, + selectedIds, + t("documents.card.untitled"), + ); + if (resolved.overCap) { + showErrorToast( + t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), + ); + return; + } + if (resolved.documents.length === 0) return; + setBulkShareDocs(resolved.documents); + setBulkShareOpen(true); + }, [documents, selectedIds, t]); + + return { + selectedIds, + toggleSelected, + clearSelection, + bulk, + runBulk, + pendingDeleteIds, + setPendingDeleteIds, + requestDelete, + confirmDelete, + bulkShareOpen, + setBulkShareOpen, + bulkShareDocs, + openBulkShare, + }; +} + +export type VaultSelection = ReturnType; diff --git a/src/components/documents/vault/vault-drop-overlay.tsx b/src/components/documents/vault/vault-drop-overlay.tsx new file mode 100644 index 000000000..7deb67648 --- /dev/null +++ b/src/components/documents/vault/vault-drop-overlay.tsx @@ -0,0 +1,30 @@ +import { UploadCloud } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The page-wide drop overlay. Pure decoration (aria-hidden): the intake + * announces through the page's live region. + */ +export function VaultDropOverlay() { + const { t } = useTranslations(); + return ( +
+
+
+ +

+ {t("documents.dropOverlay.title")} +

+

+ {t("documents.dropOverlay.hint")} +

+
+
+
+ ); +} diff --git a/src/components/documents/vault/vault-page-actions.tsx b/src/components/documents/vault/vault-page-actions.tsx new file mode 100644 index 000000000..e2b511676 --- /dev/null +++ b/src/components/documents/vault/vault-page-actions.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { RefObject } from "react"; +import Link from "next/link"; +import { Upload, Wrench } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +import { ImportFromSourceButton } from "../sources/import-from-source-button"; + +/** + * The page header's actions (rendered only when one of them applies): the display-settings wrench (the caller's own + * settings, so never inside somebody else's record), the source import and + * the upload button, which an empty vault's empty state carries instead. + */ +export function VaultPageActions({ + canManageDocuments, + inSharedRecord, + hideUpload, + uploadInputRef, +}: { + canManageDocuments: boolean; + inSharedRecord: boolean; + hideUpload: boolean; + uploadInputRef: RefObject; +}) { + const { t } = useTranslations(); + return ( +
+ {/* The wrench left of the primary action, as on the illness + journal: it opens this vault's display settings. `/settings` + is the caller's own, so it never shows inside somebody + else's record. */} + {!inSharedRecord ? ( + + ) : null} + {canManageDocuments ? ( + <> + {/* Renders only with a connected Paperless-ngx or Papra. */} + + {/* An empty vault's empty state carries the upload. */} + {hideUpload ? null : ( + + )} + + ) : null} +
+ ); +} From 58215f33380dc756364fa711ea6daa7937cd12fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:41:46 +0200 Subject: [PATCH 06/35] refactor(medications): split the dose-history ledger into its hook, row and dialogs The ledger file held two components of 404 and 314 lines. It now reads: - dose-history/use-dose-history-ledger: the window read, summary, day groups and the optimistic mark, plus the owner's edits (toggle, pin, release, delete) and one reconcile step after every write - dose-history/ledger-row-item: the row and, apart from it, the owner's row menu - dose-history/ledger-summary-chart and ledger-confirms dose-history-ledger.tsx keeps the ledger layout. Markup and behaviour are unchanged; the guards name the modules that now hold what they check. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 6 +- .../baselines/version-stamp-comments.json | 5 +- .../daily-reads-refetch-guard.test.ts | 2 +- ...ate-order-explicit-formatter-guard.test.ts | 2 +- .../destructive-control-guard.test.ts | 3 +- .../intake-toast-single-decision.test.ts | 2 +- .../success-affordance-guard.test.ts | 9 +- .../delegated-write-affordances.test.tsx | 2 +- .../medications/dose-history-ledger.tsx | 875 +----------------- .../dose-history/ledger-confirms.tsx | 101 ++ .../dose-history/ledger-row-item.tsx | 451 +++++++++ .../dose-history/ledger-summary-chart.tsx | 68 ++ .../dose-history/use-dose-history-ledger.ts | 326 +++++++ 14 files changed, 1008 insertions(+), 849 deletions(-) create mode 100644 src/components/medications/dose-history/ledger-confirms.tsx create mode 100644 src/components/medications/dose-history/ledger-row-item.tsx create mode 100644 src/components/medications/dose-history/ledger-summary-chart.tsx create mode 100644 src/components/medications/dose-history/use-dose-history-ledger.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 2bee410d8..a6acffeed 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 118, - "total": 136542, + "count": 117, + "total": 135490, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -26,7 +26,6 @@ "components/measurement-reminders/vorsorge-section.tsx": 1536, "components/measurements/measurement-form.tsx": 815, "components/medications/detail/medication-detail-tabs.tsx": 1160, - "components/medications/dose-history-ledger.tsx": 1052, "components/medications/medication-table.tsx": 853, "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 93dcdaa32..ba2445663 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 405, - "total": 140305, + "count": 403, + "total": 139587, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -183,8 +183,6 @@ "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771, "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, - "components/medications/dose-history-ledger.tsx :: DoseHistoryLedger": 404, - "components/medications/dose-history-ledger.tsx :: LedgerRowItem": 314, "components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266, "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405, "components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e546e423d..a8a35605e 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2950, - "total": 10294, + "count": 2949, + "total": 10287, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1171,7 +1171,6 @@ "components/medications/detail/medication-detail-tabs.tsx": 28, "components/medications/dose-history-add-dialog.tsx": 2, "components/medications/dose-history-ledger-compute.ts": 5, - "components/medications/dose-history-ledger.tsx": 7, "components/medications/dose-strength-curve.tsx": 2, "components/medications/drug-level-chart.tsx": 2, "components/medications/glp1-medication-card.tsx": 36, diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 97bfa839b..0102123dc 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -242,7 +242,7 @@ describe("daily-reads refetch guard", () => { expect(read(rel)).toMatch(/invalidateMedicationReads\(/); } const pairInPlace = [ - "components/medications/dose-history-ledger.tsx", + "components/medications/dose-history/use-dose-history-ledger.ts", "components/medications/dose-history-add-dialog.tsx", ]; for (const rel of pairInPlace) { diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index 8d479de9c..908784bbc 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -252,7 +252,7 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ why: "The `yyyy-MM-dd` key the ledger groups doses by. The heading a reader sees is rendered separately, through the preference-aware `formatDateWithWeekdaySmart`.", }, { - file: "src/components/medications/dose-history-ledger.tsx", + file: "src/components/medications/dose-history/use-dose-history-ledger.ts", why: "The same key shape for `today`, compared against those buckets. A comparison, not a label.", }, { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 023c98402..3e5f7f9b6 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -524,8 +524,9 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["AlertDialog"], }, { - file: "components/medications/dose-history-ledger.tsx", + file: "components/medications/dose-history/use-dose-history-ledger.ts", destroys: "one intake event", + triggers: ["components/medications/dose-history/ledger-confirms.tsx"], recovery: "tombstoned-no-restore", confirm: ["AlertDialog"], }, diff --git a/src/__tests__/intake-toast-single-decision.test.ts b/src/__tests__/intake-toast-single-decision.test.ts index 39e73fbb1..0c3927a20 100644 --- a/src/__tests__/intake-toast-single-decision.test.ts +++ b/src/__tests__/intake-toast-single-decision.test.ts @@ -151,7 +151,7 @@ describe("the intake confirmation is decided in one place", () => { expect(posting.length).toBeGreaterThan(2); for (const rel of [ "components/medications/use-medication-intake.ts", - "components/medications/dose-history-ledger.tsx", + "components/medications/dose-history/use-dose-history-ledger.ts", "components/dashboard/medication-intake-quick-add.tsx", "components/medications/dose-history-add-dialog.tsx", ]) { diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 335674dfe..e0515bbe4 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -248,8 +248,13 @@ const PINNED_AFFORDANCES: Record< "src/components/medications/dose-history-add-dialog.tsx": { "toast.success": 2, }, - "src/components/medications/dose-history-ledger.tsx": { - "text-success": 2, + "src/components/medications/dose-history/ledger-row-item.tsx": { + "text-success": 1, + }, + "src/components/medications/dose-history/ledger-summary-chart.tsx": { + "text-success": 1, + }, + "src/components/medications/dose-history/use-dose-history-ledger.ts": { "toast.success": 4, }, "src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 }, 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/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 1df7caeb2..dd39a842a 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -1,7 +1,7 @@ "use client"; /** - * v1.15.18 WE — the Verlauf tab's dose-history ledger. + * The Verlauf tab's dose-history ledger. * * Renders the server-minted ledger (`GET /api/medications/[id]/dose-history`): * EVERY expected slot with its status (pünktlich / verspätet / übersprungen / @@ -24,13 +24,13 @@ * (incl. backdated / off-schedule); an off-window take surfaces the * "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via * `forceSlotInstant` rather than orphaning it to an ad-hoc row; - * - v1.15.20: an ad-hoc row shows WHEN the dose would have been due + * - an ad-hoc row shows WHEN the dose would have been due * (`nearestSlot` subline) and — when that slot is still unserved — a * kebab "Slot … zuordnen" action that pins the take onto it after a * confirm that names the consequence ("zählt als verspätet"). A pinned * slot row carries a quiet "zugeordnet" badge and the inverse - * "Zuordnung lösen" kebab action (`forceSlotInstant: null`). v1.16.0: - * the released row keeps USER_PIN provenance server-side (the dedup + * "Zuordnung lösen" kebab action (`forceSlotInstant: null`). The + * released row keeps USER_PIN provenance server-side (the dedup * leaves it standalone) and reads as a plain ad-hoc row here — no * badge, since it is no longer bound to a slot. * @@ -39,85 +39,27 @@ * the ledger uses the semantic feedback vocabulary on the row, not the card. */ -import { DayLink } from "@/components/day/day-link"; -import { - useActiveRecordName, - useRecordCapabilities, -} from "@/hooks/use-record-capabilities"; -import { useCallback, useMemo, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertCircle, - AlertTriangle, - Check, - CircleCheck, - Link2, - Loader2, - MoreVertical, - Pencil, - Plus, - SkipForward, - Trash2, - Unlink, -} from "lucide-react"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/errors/error-toast"; +import { useState } from "react"; +import { Loader2, Plus } from "lucide-react"; +import { DayLink } from "@/components/day/day-link"; +import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog"; +import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { useAuth } from "@/hooks/use-auth"; -import { - useTranslations, - useFormatters, - useDateFormatPreference, -} from "@/lib/i18n/context"; +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { formatDateWithWeekdaySmart } from "@/lib/date-format"; import { - invalidateKeys, - medicationDependentKeys, - queryKeys, - refetchInactiveDailyReads, -} from "@/lib/query-keys"; -import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history"; -import { - intakeToastOptions, - runUndoIntake, -} from "@/components/medications/use-medication-intake"; -import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; -import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog"; -import { - applyOptimisticSlotMark, - complianceFromLedger, - formatSlotDelta, - groupLedgerByDay, - isSlotActionable, - slotMarkRequestBody, - type LedgerPayload, - type LedgerRow, -} from "@/components/medications/dose-history-ledger-compute"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { EmptyState } from "@/components/ui/empty-state"; + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; -const DAY_MS = 24 * 60 * 60 * 1000; +import { DeleteRowConfirm, PinConfirm } from "./dose-history/ledger-confirms"; +import { LedgerRowItem } from "./dose-history/ledger-row-item"; +import { LedgerSummaryChart } from "./dose-history/ledger-summary-chart"; +import { useDoseHistoryLedger } from "./dose-history/use-dose-history-ledger"; /** A schedule slot the add dialog offers for pinning. */ export interface LedgerSchedule { @@ -135,36 +77,6 @@ export interface DoseHistoryLedgerProps { windowDays?: number; } -/** Status → semantic colour token + glyph. Mirrors the card status pill's - * success / warning / destructive ramp so the surface reads as one vocabulary; - * skipped + upcoming are calm muted, ad-hoc is the neutral-positive accent. */ -const STATUS_TONE: Record = { - taken_on_time: "text-success", - taken_late: "text-warning", - skipped: "text-muted-foreground", - missed: "text-destructive", - upcoming: "text-muted-foreground", - ad_hoc: "text-primary", -}; - -function StatusGlyph({ status }: { status: DoseHistoryStatus }) { - const cls = "size-3.5 shrink-0"; - switch (status) { - case "taken_on_time": - return