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] 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).