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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:06:32 +02:00
1 parent 9de64cae3e
commit e25b6365fd
27 files changed
+2179 -2425

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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"),
];
@@ -59,7 +59,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
* screen unrounded.
*/
const RAW_SCALE_SURFACES = [
"app/page-client.tsx",
// The dashboard's chart cards; the values the dashboard renders itself
// convert in the tile context (see `CONVERTS_IN`).
"components/dashboard/tiles/chart-tiles.tsx",
// The insights trends row: a single transformed series hands its scale
// and offset to the mini chart, which formats every value itself.
"components/insights/trends-row.tsx",
@@ -68,6 +70,16 @@ const RAW_SCALE_SURFACES = [
"components/insights/device-score-tile.tsx",
];
/**
* A surface whose rendered values convert in a sibling module: the dashboard's
* chart cards read the preference through the tile context every dashboard
* tile is built from.
*/
const CONVERTS_IN: Readonly<Record<string, string>> = {
"components/dashboard/tiles/chart-tiles.tsx":
"components/dashboard/tiles/tile-context.ts",
};
/** Matches a `factor` / `offset` read off a display transform. */
const RAW_SCALE_READ =
/\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/;
@@ -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([]);
});
+37
View File
@@ -0,0 +1,37 @@
/**
* The dashboard's source, for the guards that pin how it is wired.
*
* The dashboard is a composer (`src/app/page-client.tsx`) over a tile
* registry (`src/components/dashboard/tiles/`): the reads, the tile context,
* the strip and chart definitions and the rendering of both rows each live in
* their own module there. A guard that used to read the one page file reads
* all of them through here, so a rule about the dashboard holds wherever in
* the registry the code it watches sits, and moving code between the files
* cannot turn a negative assertion green. The folder read carries a floor,
* and the page read an anchor, so a rename fails loudly instead of returning
* less text.
*/
import { readSource, readSourceFolder } from "./source-files";
export const DASHBOARD_PAGE = "src/app/page-client.tsx";
export const DASHBOARD_TILES_DIR = "src/components/dashboard/tiles";
/** Each dashboard module as `[repo-relative path, text]`. */
export function dashboardSourceFiles(): Array<[string, string]> {
const page = readSource(DASHBOARD_PAGE, "DashboardPageClient");
const tiles = readSourceFolder(DASHBOARD_TILES_DIR, {
floor: 5,
include: (rel) => !rel.includes("__tests__"),
}).map(([rel, text]): [string, string] => [
`${DASHBOARD_TILES_DIR}/${rel}`,
text,
]);
return [[DASHBOARD_PAGE, page], ...tiles];
}
/** The dashboard modules' text, concatenated in a stable order. */
export function dashboardSource(): string {
return dashboardSourceFiles()
.map(([, text]) => text)
.join("\n");
}
@@ -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/,
},
],
},
{
@@ -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"),
@@ -2,67 +2,54 @@
* The dashboard weight tile colours its trend by the resolved target
* direction, never by a literal (#1006).
*
* Every other tile on the strip passes a literal `directionSentiment`, because
* for resting pulse or steps the good direction is a property of the metric.
* For resting pulse or steps the good direction is a property of the metric.
* Weight is the exception: which way is progress depends on the person's own
* target, and it is resolved on the server (`tiles.weightTrend` on the
* snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` here is
* exactly the defect the issue reported — a person below their target saw
* every gain coloured as a setback — and it would type-check and render
* without complaint, so this pins the attribute rather than the pixels.
* snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` is
* exactly the defect the issue reported (a person below their target saw
* every gain coloured as a setback), and it would type-check and render
* without complaint.
*
* Structural rather than rendered because the dashboard page early-returns a
* loading gate under SSR, and the property is about the source: the weight
* `<TrendCard>` names `weightTrend.direction`, or it does not.
* The tile is built from the registry with each resolved direction in turn,
* and the card has to carry the one it was handed.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import type { ReactElement } from "react";
import { describe, expect, it } from "vitest";
const PAGE = join(process.cwd(), "src/app/page-client.tsx");
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
function weightTileDirections(): string[] {
const text = readFileSync(PAGE, "utf8");
const file = ts.createSourceFile(
PAGE,
text,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const found: string[] = [];
const visit = (node: ts.Node) => {
if (
ts.isJsxSelfClosingElement(node) &&
node.tagName.getText(file) === "TrendCard"
) {
const attrs = node.attributes.properties.filter(ts.isJsxAttribute);
const key = attrs.find((a) => a.name.getText(file) === "key");
const isWeight =
key?.initializer &&
ts.isStringLiteral(key.initializer) &&
key.initializer.text === "weight";
if (isWeight) {
const direction = attrs.find(
(a) => a.name.getText(file) === "directionSentiment",
);
found.push(direction?.initializer?.getText(file) ?? "<absent>");
}
}
ts.forEachChild(node, visit);
};
visit(file);
return found;
function weightCards(direction: string) {
const ctx = {
t: (key: string) => key,
fmt: { number: (v: number) => String(v) },
td: (_type: string, v: number | null | undefined) => v ?? null,
tdd: (_type: string, v: number | null | undefined) => v ?? null,
unitDisplay: { unitFor: () => "kg" },
summaries: { WEIGHT: { count: 3, latest: 80, avg7: 80, avg30: 81 } },
weightRange: null,
weightHintRange: null,
weightTrend: { direction },
compareBaseline: "none",
tileCompareDelta: () => null,
tileStaleDays: () => null,
directionOf: () => "up-bad",
} as unknown as DashboardTileContext;
const tiles = STRIP_TILES.filter((tile) => tile.widget === "weight");
return tiles.flatMap((tile) => tile.cards(ctx));
}
describe("dashboard weight tile direction", () => {
it("finds exactly one weight tile, so an empty match cannot pass", () => {
expect(weightTileDirections()).toHaveLength(1);
expect(weightCards("down-good")).toHaveLength(1);
});
it("reads the resolved target direction rather than a literal", () => {
expect(weightTileDirections()).toEqual(["{weightTrend.direction}"]);
for (const direction of ["down-good", "up-good", "neutral"]) {
const [card] = weightCards(direction);
const props = (card.node as ReactElement<{ directionSentiment: string }>)
.props;
expect(props.directionSentiment).toBe(direction);
}
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
/**
* v1.4.39.2 — dashboard analytics slim/thick split.
@@ -26,16 +25,10 @@ import { join } from "node:path";
* slim/thick split still drives the page, so the protective intent
* holds; the matchers only relaxed to tolerate the `enabled` arg.
*/
const ROOT = join(__dirname, "../../..");
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.39.2 — dashboard analytics slim/thick split", () => {
it("mounts both the slim slice and the thick slice in parallel", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// Slim slice — `?slice=summaries` paints the per-type tile strip.
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/);
// Thick slice — the no-slice `useAnalyticsQuery` feeds the
@@ -45,7 +38,7 @@ describe("v1.4.39.2 — dashboard analytics slim/thick split", () => {
});
it("merges slim and thick results so call-sites stay shape-stable", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// The merge contract: slim wins on overlapping fields, thick fills
// in `bpInTargetPct*` and `glucoseByContext`. The text anchors
// protect against a future refactor accidentally dropping one of
@@ -21,11 +21,17 @@
* `resolveChartRevealState` resolver, the markup through
* `renderToStaticMarkup`, and the page wiring through textual pins.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ReactElement } from "react";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import {
CHART_TILES,
revealChartIds,
type ChartTileContext,
} from "@/components/dashboard/tiles/chart-tiles";
import {
CHART_REVEAL_TIMEOUT_MS,
DashboardChartCell,
@@ -34,8 +40,6 @@ import {
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { I18nProvider } from "@/lib/i18n/context";
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
describe("resolveChartRevealState", () => {
it("stays hidden while the gate is not armed (no visible charts yet)", () => {
expect(
@@ -145,46 +149,65 @@ describe("TrendCardSkeleton", () => {
});
});
describe("dashboard chart-reveal wiring (page.tsx)", () => {
const src = readFileSync(PAGE_PATH, "utf8");
describe("dashboard chart-reveal wiring (the chart registry)", () => {
const GATED = [
"weight-chart",
"bmi-chart",
"bp-chart",
"pulse-chart",
"bodyFat-chart",
"mood-chart",
"sleep-chart",
"steps-chart",
"medications",
];
it("derives the gated id list from the chart visibility gates", () => {
expect(src).toMatch(/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/);
expect(src).toMatch(/useDashboardChartReveal\(revealChartIds\)/);
it("derives the gated id list from the visible chart cards", () => {
expect(revealChartIds(CHART_TILES)).toEqual(GATED);
// A hidden card drops out of the list the reveal waits for.
expect(
revealChartIds(CHART_TILES.filter((tile) => tile.id !== "bp-chart")),
).toEqual(GATED.filter((id) => id !== "bp-chart"));
expect(dashboardSource()).toMatch(
/useDashboardChartReveal\(revealChartIds\(chartTiles\)\)/,
);
});
it("every data-backed chart reports through onDataReady", () => {
for (const id of [
"weight-chart",
"bmi-chart",
"bp-chart",
"pulse-chart",
"bodyFat-chart",
"mood-chart",
"sleep-chart",
"steps-chart",
"medications",
]) {
expect(src).toContain(`onDataReady={() => markChartReady("${id}")}`);
const markChartReady = vi.fn();
const ctx = {
t: (key: string) => key,
unitDisplay: {
unitFor: () => "kg",
transformFor: () => ({ factor: 1, offset: 0 }),
},
user: { heightCm: 180, timezone: "Europe/Berlin" },
summaries: {},
compareBaseline: "none",
preloadedSeries: undefined,
markChartReady,
} as unknown as ChartTileContext;
for (const tile of CHART_TILES.filter((t) => GATED.includes(t.id))) {
const node = tile.render(ctx) as ReactElement<{
onDataReady?: () => void;
}>;
node.props.onDataReady?.();
expect(markChartReady).toHaveBeenLastCalledWith(tile.id);
}
expect(markChartReady).toHaveBeenCalledTimes(GATED.length);
});
it("gated entries render inside DashboardChartCell", () => {
expect(src).toMatch(
expect(dashboardSource()).toMatch(
/entry\.revealGated\s*\?[\s\S]*?<DashboardChartCell revealed=\{chartsRevealed\}>/,
);
});
it("self-skeletoning cards (achievements / workouts) stay outside the gate", () => {
// Their entries never set `revealGated`, so the ungated branch
// renders them directly.
const achievements = src.match(
/charts\.push\(\{\s*id:\s*"achievements"[\s\S]*?\}\);/,
);
const workouts = src.match(
/charts\.push\(\{\s*id:\s*"recentWorkouts"[\s\S]*?\}\);/,
);
expect(achievements?.[0]).not.toContain("revealGated");
expect(workouts?.[0]).not.toContain("revealGated");
for (const id of ["achievements", "recentWorkouts", "vorsorge"]) {
const tile = CHART_TILES.find((t) => t.id === id);
expect(tile, id).toBeDefined();
expect(tile?.revealGated, id).toBeUndefined();
}
});
});
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -13,7 +14,6 @@ import { join } from "node:path";
* client mounts the checklist only without it.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
const CLIENT_PATH = join(process.cwd(), "src/app/page-client.tsx");
describe("dashboard setup checklist on a demo instance", () => {
it("threads DEMO_MODE into every render of the dashboard client", () => {
@@ -27,7 +27,7 @@ describe("dashboard setup checklist on a demo instance", () => {
});
it("mounts the checklist only outside demo mode", () => {
const src = readFileSync(CLIENT_PATH, "utf8");
const src = dashboardSource();
const mounts = src.match(/<GettingStartedChecklist\b[^>]*\/>/g) ?? [];
expect(mounts).toHaveLength(1);
expect(src).toContain("{demoMode ? null : <GettingStartedChecklist />}");
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -16,14 +17,13 @@ import { join } from "node:path";
* once carried it — is gone), while the page itself derives no
* welcome text.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
const HEADER_PATH = join(
process.cwd(),
"src/components/dashboard/dashboard-header.tsx",
);
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
it("mounts the TodayHero above the tile strip", () => {
const heroIdx = src.indexOf("<TodayHero");
@@ -80,7 +80,7 @@ describe("dashboard hero — header greeting", () => {
});
it("the page mounts the header without a greeting prop", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toContain(
"<DashboardHeader onQuickEntry={setQuickEntryDialog} />",
);
@@ -1,3 +1,4 @@
import type { ReactElement } from "react";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -19,7 +20,8 @@ import {
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { locales } from "@/lib/i18n/config";
import type { DataSummary } from "@/lib/analytics/trends";
import { readSource } from "@/__tests__/helpers/source-files";
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
/**
* The dashboard HRV tile must accept either HRV type.
@@ -245,20 +247,37 @@ describe("the tile the user actually sees", () => {
}
});
it("does not read freshness off a hardcoded SDNN type", () => {
// The one source-level check kept, and deliberately a NEGATIVE one: it
// names the exact expression the bug lived on rather than asserting the
// current formatting of the replacement, so reformatting cannot break
// it. Freshness is per-type and captioning a live RMSSD tile from the
// SDNN lookup is the failure it guards.
const page = readSource("src/app/page-client.tsx", [
"const tileStaleDays",
"HEART_RATE_VARIABILITY",
]);
expect(page).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")');
expect(page).not.toContain(
"const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY",
);
it("reads freshness off the series the tile shows", () => {
// Freshness is per type, and captioning a live RMSSD tile from the SDNN
// lookup marked it permanently stale. The registry's HRV tile is built
// with a context whose freshness differs per type, so the caption it
// asks for names the type it used.
const hrvTile = STRIP_TILES.find((tile) => tile.widget === "hrv");
expect(hrvTile).toBeDefined();
for (const [summaries, type, days] of [
[{ HRV_RMSSD: summary(30, 42) }, "HRV_RMSSD", 12],
[
{ HEART_RATE_VARIABILITY: summary(9, 55) },
"HEART_RATE_VARIABILITY",
20,
],
] as const) {
const ctx = {
t: (key: string) => key,
summaries,
hrv: pickHrvSummary(summaries),
compareBaseline: "none",
directionOf: () => "neutral",
tileCompareDelta: () => null,
tileStaleDays: (t: string | null | undefined) =>
t === "HRV_RMSSD" ? 12 : t === "HEART_RATE_VARIABILITY" ? 20 : null,
} as unknown as DashboardTileContext;
expect(hrvTile?.visible(ctx), type).toBe(true);
const [card] = hrvTile?.cards(ctx) ?? [];
const props = (card.node as ReactElement<{ staleDays: number | null }>)
.props;
expect(props.staleDays, type).toBe(days);
}
});
});
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { readSource } from "@/__tests__/helpers/source-files";
/**
* v1.4.40 — dashboard Suspense boundaries.
@@ -22,22 +22,16 @@ import { join } from "node:path";
* a future refactor that drops the boundary lands a failing test
* rather than silently regressing the streaming-composition contract.
*/
const ROOT = join(__dirname, "../../..");
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("imports Suspense from React", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
expect(src).toMatch(/from\s+"react"/);
expect(src).toMatch(/\bSuspense\b/);
});
it("wraps the chart-row cell in a `<Suspense fallback={<ChartSkeleton />}>` boundary", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// `<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>`
// exact contract: the chart row's per-cell fallback uses the same
// ChartSkeleton primitive the dynamic-loaded charts already paint
@@ -48,7 +42,7 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
});
it("wraps each tile-strip cell in a `<Suspense>` boundary with a layout-stable placeholder", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null`
// fallback for an `aria-hidden` placeholder div; v1.16.0 upgraded
// it to the structured `<TrendCardSkeleton>` silhouette (the
@@ -64,18 +58,20 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
});
it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => {
const src = load(PAGE_PATH);
// Module-scope declaration — search outside the component body.
// Pre-fix the const was declared inside `DashboardPage` so every
// render created a fresh `{}` reference.
const moduleSliceEnd = src.indexOf("export default function DashboardPage");
const src = readSource(
"src/components/dashboard/tiles/use-dashboard-data.ts",
"export function useDashboardData",
);
// Module-scope declaration — search outside the hook body. Declared
// inside it, every render would create a fresh `{}` reference.
const moduleSliceEnd = src.indexOf("export function useDashboardData");
expect(moduleSliceEnd).toBeGreaterThan(0);
const moduleSlice = src.slice(0, moduleSliceEnd);
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
const src = dashboardSource();
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// moved off the page with the greeting itself: the greeting now
// lives in the header (`dashboard-header.tsx`), so the page no
@@ -1,12 +1,11 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
resolveChartRowPlaceholderCount,
resolveConfiguredTileCount,
resolveDashboardFirstPaintGate,
} from "../page-client";
} from "@/components/dashboard/dashboard-gates";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import {
DEFAULT_DASHBOARD_LAYOUT,
type DashboardLayout,
@@ -33,7 +32,6 @@ import {
* snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the
* behaviour; textual pins keep the wiring honest.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
describe("resolveDashboardFirstPaintGate", () => {
const base = {
@@ -173,14 +171,14 @@ describe("resolveChartRowPlaceholderCount (v1.16.8)", () => {
describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
it("derives `configuredTileCount` through the tile-capable resolver", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/const\s+configuredTileCount\s*=\s*resolveConfiguredTileCount\(layout\);/,
);
});
it("reserves the chart row while the primary query loads", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
// The placeholder count keys off the layout config minus the
// already-mounted (layout-gated) cells, and only while loading.
// `mounted &&` pins the hydration render to the SSR output — the
@@ -189,7 +187,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
expect(src).toMatch(
/const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*mounted\s*&&\s*Boolean\(user\?\.heightCm\)/,
);
expect(src).toMatch(/-\s*charts\.length/);
expect(src).toMatch(/-\s*chartEntries\.length/);
// The reserved cells render as aria-hidden ChartSkeletons in their
// own slot.
const skeletonBlock = src.match(
@@ -201,7 +199,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
});
it("derives `primaryLoading` from the snapshot vs slim query", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
// v1.16.4 — `!mounted` pins SSR + the hydration render to the
// skeleton branch so a late-hydrating boundary can't disagree with
// the server HTML (React #418); see `useMounted`.
@@ -211,21 +209,21 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
});
it("routes the gate through `resolveDashboardFirstPaintGate`", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/resolveDashboardFirstPaintGate\(\{[\s\S]*?primaryLoading,[\s\S]*?\}\)/,
);
});
it("renders the skeleton with the same auto-fit grid track the real strip uses", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/showTileStripSkeleton\s*&&\s*\(\s*<div[\s\S]*?\[grid-template-columns:repeat\(auto-fit,minmax\(min\(100%,11rem\),1fr\)\)\]/,
);
});
it("keys the skeleton card count off configuredTileCount", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(
/Array\.from\(\{\s*length:\s*configuredTileCount\s*\}\)/,
);
@@ -237,7 +235,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
// Reduced motion moved into the component (the `<Skeleton>` primitive
// carries `motion-reduce:animate-none`); the per-component test in
// `dashboard-chart-reveal.test.tsx` pins that.
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
const skeletonBlock = src.match(
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
);
@@ -248,12 +246,12 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
});
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/);
});
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const src = dashboardSource();
expect(src).toMatch(/if\s*\(showEmptyState\)\s*\{/);
});
});
+37 -12
View File
@@ -1,7 +1,12 @@
import type { ReactElement } from "react";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
/**
* v1.4.19 — guards for the polish pass on `/insights` and the
* dashboard:
@@ -28,7 +33,6 @@ import { join } from "node:path";
*/
const ROOT = join(__dirname, "../../..");
const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
@@ -38,7 +42,7 @@ function load(path: string): string {
describe("v1.4.19 A3 — dashboard polish", () => {
it("dashboard page does NOT render <CompareToggle />", () => {
const src = load(DASHBOARD_PATH);
const src = dashboardSource();
// The component import + JSX use are both gone. The toggle still
// lives on /insights via a separate import there.
expect(src).not.toMatch(/<CompareToggle\b/);
@@ -52,9 +56,9 @@ describe("v1.4.19 A3 — dashboard polish", () => {
// baseline plumbing along with the toggle, every chart on the
// dashboard would lose its comparison overlay silently. The
// resolved layout still carries the value to each chart.
const src = load(DASHBOARD_PATH);
expect(src).toContain("comparisonBaseline");
expect(src).toContain("compareBaseline");
const src = dashboardSource();
expect(src).toContain("layout.comparisonBaseline");
expect(src).toContain("compareBaseline={ctx.compareBaseline}");
});
});
@@ -203,13 +207,34 @@ describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta us
* window (priorMonth / priorYear) instead of all-time.
*/
it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => {
const src = load(DASHBOARD_PATH);
// The fix introduces `bpComparePrior` keyed by `compareBaseline`.
expect(src).toContain("bpInTargetPctPriorMonth");
expect(src).toContain("bpInTargetPctPriorYear");
// The all-time shortcut from v1.4.22 A2 must not subtract `bpAll`
// for the comparison delta any more.
expect(src).not.toMatch(/bp30\s*-\s*bpAll/);
// The tile is built from the registry with a 30-day share of 80 % and
// prior periods of 70 % (month), 50 % (year) and 40 % (all time): the
// delta follows the chosen baseline and never the all-time share.
const tile = STRIP_TILES.find((t) => t.widget === "bpInTarget");
const deltaFor = (compareBaseline: string) => {
const ctx = {
t: (key: string) => key,
compareBaseline,
directionOf: () => "neutral",
data: {
bpInTargetPct: 75,
bpInTargetPct7d: 78,
bpInTargetPct30d: 80,
bpInTargetPctAllTime: 40,
bpInTargetPctPriorMonth: 70,
bpInTargetPctPriorYear: 50,
bpInTargetCount90: 200,
bpInTargetSpanDays90: 90,
},
} as unknown as DashboardTileContext;
expect(tile?.visible(ctx)).toBe(true);
const [card] = tile?.cards(ctx) ?? [];
return (card.node as ReactElement<{ compareDelta: number | null }>).props
.compareDelta;
};
expect(deltaFor("lastMonth")).toBe(10);
expect(deltaFor("lastYear")).toBe(30);
expect(deltaFor("none")).toBeNull();
});
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
@@ -1,3 +1,4 @@
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
@@ -86,10 +87,7 @@ describe("RootLoading — single skeleton source (wiring pins)", () => {
join(process.cwd(), "src/app/loading.tsx"),
"utf8",
);
const pageSrc = readFileSync(
join(process.cwd(), "src/app/page-client.tsx"),
"utf8",
);
const pageSrc = dashboardSource();
it("imports the same silhouette components the page's loading phase uses", () => {
expect(loadingSrc).toContain(
+96 -2231
View File
File diff suppressed because it is too large. Load diff
@@ -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<Record<TileCapableWidgetId, string>> = {};
/** 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<string>();
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-]+"/);
});
@@ -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 `<ChartErrorBoundary>` so a
// chunk that still fails degrades to one error card instead of the route's
// `error.tsx`.
const MoodChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.MoodChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const MoodChart = (props: ComponentProps<typeof MoodChartLazy>) => (
<ChartErrorBoundary>
<MoodChartLazy {...props} />
</ChartErrorBoundary>
);
const MedicationComplianceChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.MedicationComplianceChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const MedicationComplianceChart = (
props: ComponentProps<typeof MedicationComplianceChartLazy>,
) => (
<ChartErrorBoundary>
<MedicationComplianceChartLazy {...props} />
</ChartErrorBoundary>
);
// Warm the shared `chart-runtime` chunk during hydration. The charts mount
// through `ssr: false` dynamics, which start their import only at mount;
// firing it at module evaluation lets the download overlap hydration. The
// dynamics resolve through the same `importWithRetry` promise.
if (typeof window !== "undefined") {
void importWithRetry(() => import("@/components/charts/chart-runtime"));
}
/** What a chart card needs beyond the tile context. */
export interface ChartTileContext extends DashboardTileContext {
preloadedSeries: Record<string, BatchedSeriesRow[]> | undefined;
markChartReady: (id: string) => void;
}
/** One card in the chart row, positioned by `order`. */
export interface ChartEntry {
id: string;
order: number;
node: ReactNode;
/**
* Total raw readings for the metric. Under five, a "first trend after 5
* readings" hint sits under the chart; undefined disables the hint.
*/
count?: number;
/**
* The cell holds its skeleton until every gated chart's data settled (or
* the reveal timeout fired). Data-backed charts set this; the cards that
* skeleton themselves do not.
*/
revealGated?: boolean;
}
/**
* A chart-row card: the layout widget that switches and orders it, the card's
* own rule (a data floor, a module, a resolved layout), and the card itself.
* Unlike the strip, the chart row reads the widget's chart flag (`visible`),
* which `buildChartEntries` applies for every card.
*/
interface ChartTile {
id: string;
widget: string;
orderOffset?: number;
revealGated?: boolean;
visible: (ctx: DashboardTileContext) => boolean;
count?: (ctx: DashboardTileContext) => number;
render: (ctx: ChartTileContext) => ReactNode;
}
const countOf = (summary: { count?: number } | null | undefined) =>
summary?.count ?? 0;
const BMI_BANDS = [
{ min: 0, max: 17, color: "var(--destructive)", opacity: 0.16 },
{ min: 17, max: 18.5, color: "var(--warning)", opacity: 0.18 },
{ min: 18.5, max: 24.9, color: "var(--success)", opacity: 0.2 },
{ min: 24.9, max: 29.9, color: "var(--warning)", opacity: 0.18 },
{ min: 29.9, max: 120, color: "var(--destructive)", opacity: 0.16 },
];
const hasRestingHr = (ctx: DashboardTileContext) =>
countOf(ctx.summaries?.RESTING_HEART_RATE) > 0;
/** 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) => (
<HealthChartDynamic
key="weight-chart"
onDataReady={() => 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) => (
<HealthChartDynamic
key="bmi-chart"
onDataReady={() => 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) => (
<HealthChartDynamic
key="bp-chart"
onDataReady={() => 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) => (
<HealthChartDynamic
key="pulse-chart"
onDataReady={() => 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) => (
<HealthChartDynamic
key="bodyFat-chart"
onDataReady={() => 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) => (
<MoodChart
key="mood-chart"
onDataReady={() => ctx.markChartReady("mood-chart")}
compareBaseline={ctx.compareBaseline}
chartKey="mood"
dayLinks
/>
),
},
{
// Sleep is never batched: it reads its own per-night series.
id: "sleep-chart",
widget: "sleep",
revealGated: true,
visible: (ctx) => countOf(ctx.summaries?.SLEEP_DURATION) > 0,
count: (ctx) => ctx.summaries?.SLEEP_DURATION?.count ?? 0,
render: (ctx) => (
<HealthChartDynamic
key="sleep-chart"
onDataReady={() => 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) => (
<HealthChartDynamic
key="steps-chart"
onDataReady={() => 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) => (
<MedicationComplianceChart
key="medications"
onDataReady={() => ctx.markChartReady("medications")}
compareBaseline={ctx.compareBaseline}
userTimezone={ctx.user?.timezone}
dayLinks
/>
),
},
{
// The preventive-care summary: fetches its own list and skeletons itself,
// so it stays out of the reveal gate.
id: "vorsorge",
widget: "vorsorge",
visible: () => true,
render: () => <VorsorgeDashboardCard key="vorsorge" />,
},
{
// No data floor (the empty card is how the feature is discovered), so it
// waits for the real layout: on the fallback layout it would flash in and
// retract for a person who switched it off.
id: "achievements",
widget: "achievements",
visible: (ctx) => ctx.layoutResolved,
render: () => <RecentAchievementsCard key="achievements" />,
},
{
// Same layout wait as the achievements card; the tile gates on its own
// query and shows the Apple Health hint when empty.
id: "recentWorkouts",
widget: "recentWorkouts",
visible: (ctx) =>
ctx.layoutResolved && ctx.user?.modules?.workouts !== false,
render: () => <RecentWorkoutsTile key="recentWorkouts" />,
},
];
/** The chart-row cards whose widget chart flag is on and whose rule passes. */
export function visibleChartTiles(
ctx: DashboardTileContext,
): readonly ChartTile[] {
return CHART_TILES.filter(
(tile) => ctx.isChartVisible(tile.widget) && tile.visible(ctx),
);
}
/** The ids the shared chart reveal waits for, in card order. */
export function revealChartIds(tiles: readonly ChartTile[]): string[] {
return tiles.filter((tile) => tile.revealGated).map((tile) => tile.id);
}
/** The chart row for this render, sorted by layout order. */
export function buildChartEntries(
tiles: readonly ChartTile[],
ctx: ChartTileContext,
): ChartEntry[] {
const entries: ChartEntry[] = tiles.map((tile) => ({
id: tile.id,
order: ctx.widgetOrder(tile.widget) + (tile.orderOffset ?? 0),
...(tile.count ? { count: tile.count(ctx) } : {}),
...(tile.revealGated ? { revealGated: true } : {}),
node: tile.render(ctx),
}));
entries.sort((a, b) => a.order - b.order);
return entries;
}
@@ -0,0 +1,196 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { Activity, Plus } from "lucide-react";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { TrendHint } from "@/components/charts/trend-hint";
import { DashboardChartCell } from "@/components/dashboard/chart-reveal";
import {
resolveChartRowPlaceholderCount,
resolveConfiguredTileCount,
resolveDashboardFirstPaintGate,
} from "@/components/dashboard/dashboard-gates";
import { dashboardTileHref } from "@/components/dashboard/tile-destinations";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import type { AuthUser } from "@/hooks/use-auth";
import type { DashboardLayout } from "@/lib/dashboard-layout";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ChartEntry } from "./chart-tiles";
import type { StripEntry } from "./strip-tiles";
/**
* The tile strip and the chart row, with the states around them: the
* layout-stable silhouettes while the primary read is in flight, and the
* empty state once it resolved with nothing to show.
*/
export function DashboardTiles({
stripEntries,
chartEntries,
chartsRevealed,
layout,
primaryLoading,
mounted,
user,
canAddMeasurement,
onAddMeasurement,
}: {
stripEntries: StripEntry[];
chartEntries: ChartEntry[];
chartsRevealed: boolean;
layout: DashboardLayout;
primaryLoading: boolean;
mounted: boolean;
user: AuthUser | null;
canAddMeasurement: boolean;
onAddMeasurement: () => void;
}) {
const { t } = useTranslations();
// The skeleton strip reserves the footprint of the tiles the layout
// configures (tile-capable widgets only, two for blood pressure), so the
// strip does not reshuffle when the data lands.
const configuredTileCount = resolveConfiguredTileCount(layout);
// While the snapshot is in flight the data-gated charts cannot exist yet;
// silhouettes for the charts the layout expects hold the page near its
// final height, and the landing snapshot swaps content instead of growing
// the page. `mounted &&` pins the hydration render to the server output:
// the shell's auth read can already carry a height when this boundary
// replays its first render, and an extra BMI silhouette would mismatch
// the server HTML (React #418).
const chartRowPlaceholderCount = primaryLoading
? Math.max(
0,
resolveChartRowPlaceholderCount(layout, {
hasHeightCm: mounted && Boolean(user?.heightCm),
}) - chartEntries.length,
)
: 0;
const { showTileStripSkeleton, showEmptyState } =
resolveDashboardFirstPaintGate({
trendCardCount: stripEntries.length,
chartCount: chartEntries.length,
configuredTileCount,
primaryLoading,
});
// Nothing to show once the read resolved: one call to action into the
// quick-entry sheet instead of an empty strip under the header.
if (showEmptyState) {
return (
<EmptyState
icon={<Activity className="size-6" />}
title={t("dashboard.emptyTitle")}
description={t("dashboard.emptyDescription")}
action={
canAddMeasurement ? (
<Button size="sm" onClick={onAddMeasurement}>
<Plus className="h-4 w-4" />
{t("dashboard.emptyAddMeasurement")}
</Button>
) : undefined
}
/>
);
}
return (
<>
{showTileStripSkeleton && (
<div
// Mirrors the real grid track, keyed off the configured tile count:
// a strip trimmed to three tiles shows three silhouettes.
aria-hidden="true"
data-slot="dashboard-tile-strip-skeleton"
className={cn(
"grid auto-rows-fr gap-3",
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
)}
>
{Array.from({ length: configuredTileCount }).map((_, idx) => (
<TrendCardSkeleton key={`tile-skeleton-${idx}`} />
))}
</div>
)}
{stripEntries.length > 0 && (
<div
// One grid track for every breakpoint, never a second row of
// scrolling: `auto-fit` with an 11 rem floor gives one column on a
// 280 px fold, two on a phone, six at 1440 px and eight from
// 1920 px, and `auto-rows-fr` keeps every row on one height so a
// tile with a callout does not break the baseline. With every tile
// switched off the strip is not rendered at all.
className={cn(
"grid auto-rows-fr gap-3",
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
)}
data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip"
data-tile-count={stripEntries.length}
>
{stripEntries.map((entry) => {
// A layout-stable fallback per tile, so a tile slot that ever
// suspends previews its shape instead of leaving the cell empty.
const cell = (
<Suspense fallback={<TrendCardSkeleton />}>{entry.node}</Suspense>
);
// A tile links to its Insights page (gated on that page's
// module); the link fills the cell and rings on hover and focus.
const href = dashboardTileHref(entry.id, user?.modules);
return href ? (
<Link
key={entry.id}
href={href}
data-slot="dashboard-tile-link"
data-tile-id={entry.id}
className="group focus-visible:ring-ring/50 hover:ring-ring/30 flex min-w-0 rounded-xl transition-[box-shadow] hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
>
{cell}
</Link>
) : (
<div key={entry.id} className="flex min-w-0">
{cell}
</div>
);
})}
</div>
)}
{chartEntries.map((entry) => (
<div key={entry.id} className="space-y-2">
{/* A boundary per chart, so each paints on its own instead of the
row waiting for the slowest. A gated cell keeps its chart
mounted (its query fires) but shows the skeleton until the
shared reveal flips; the trend hint rides inside the cell so it
appears with its chart. */}
{entry.revealGated ? (
<DashboardChartCell revealed={chartsRevealed}>
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
{entry.count != null ? <TrendHint count={entry.count} /> : null}
</DashboardChartCell>
) : (
<>
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
{entry.count != null ? <TrendHint count={entry.count} /> : null}
</>
)}
</div>
))}
{chartRowPlaceholderCount > 0 && (
<div
aria-hidden="true"
data-slot="dashboard-chart-row-skeleton"
className="space-y-6"
>
{Array.from({ length: chartRowPlaceholderCount }).map((_, idx) => (
<ChartSkeleton key={`chart-row-skeleton-${idx}`} />
))}
</div>
)}
</>
);
}
@@ -0,0 +1,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 (
<TrendCard
key={id}
fractionDigits={props.fractionDigits}
label={props.label}
latest={value(s?.latest)}
unit={props.unit}
avg7={value(s?.avg7)}
avg30={value(s?.avg30)}
slope30={s?.slope30 ?? null}
trend7Delta={delta(summaryToTrend7Delta(s)) as number | null}
icon={props.icon}
directionSentiment={ctx.directionOf(props.type)}
compareBaseline={ctx.compareBaseline}
compareDelta={delta(ctx.tileCompareDelta(s)) as number | null}
staleDays={ctx.tileStaleDays(
props.staleType === undefined ? props.type : props.staleType,
)}
/>
);
}
/**
* 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: (
<TrendCard
key="weight"
label={ctx.t("dashboard.weightShort")}
latest={ctx.td("WEIGHT", w?.latest)}
unit={unit}
avg7={ctx.td("WEIGHT", w?.avg7)}
avg30={ctx.td("WEIGHT", w?.avg30)}
avg7ColorClass={getRangeColorClass(w?.avg7, {
range: ctx.weightRange,
})}
avg30ColorClass={getRangeColorClass(w?.avg30, {
range: ctx.weightRange,
})}
avg7Hint={hint}
avg30Hint={hint}
slope30={w?.slope30 ?? null}
trend7Delta={ctx.tdd("WEIGHT", summaryToTrend7Delta(w))}
icon={Activity}
directionSentiment={ctx.weightTrend.direction}
compareBaseline={ctx.compareBaseline}
compareDelta={ctx.tdd("WEIGHT", ctx.tileCompareDelta(w))}
staleDays={ctx.tileStaleDays("WEIGHT")}
/>
),
},
];
},
},
{
// 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: (
<TrendCard
key={id}
fractionDigits={0}
label={label}
latest={s?.latest ?? null}
unit="mmHg"
avg7={s?.avg7 ?? null}
avg30={s?.avg30 ?? null}
avg7ColorClass={getRangeColorClass(s?.avg7, { range })}
avg30ColorClass={getRangeColorClass(s?.avg30, { range })}
avg7Hint={getRangeHint("mmHg", { range }, ctx.t, ctx.fmt.number)}
avg30Hint={getRangeHint("mmHg", { range }, ctx.t, ctx.fmt.number)}
slope30={s?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(s)}
icon={Heart}
directionSentiment={ctx.directionOf(type)}
compareBaseline={ctx.compareBaseline}
compareDelta={ctx.tileCompareDelta(s)}
staleDays={ctx.tileStaleDays(type)}
/>
),
};
}),
},
{
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: (
<TrendCard
key="pulse"
fractionDigits={0}
label={
hasRestingHr
? ctx.t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE)
: ctx.t("dashboard.pulseShort")
}
latest={s?.latest ?? null}
unit="bpm"
avg7={s?.avg7 ?? null}
avg30={s?.avg30 ?? null}
avg7ColorClass={getRangeColorClass(s?.avg7, { range })}
avg30ColorClass={getRangeColorClass(s?.avg30, { range })}
avg7Hint={getRangeHint("bpm", { range }, ctx.t, ctx.fmt.number)}
avg30Hint={getRangeHint("bpm", { range }, ctx.t, ctx.fmt.number)}
slope30={s?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(s)}
icon={TrendingUp}
directionSentiment={ctx.directionOf(type)}
compareBaseline={ctx.compareBaseline}
compareDelta={ctx.tileCompareDelta(s)}
staleDays={ctx.tileStaleDays(type)}
/>
),
},
];
},
},
{
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: (
<TrendCard
key="mood"
label={ctx.t("dashboard.moodShort")}
latest={s?.latest ?? null}
unit="/ 5"
avg7={s?.avg7 ?? null}
avg30={s?.avg30 ?? null}
slope30={s?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(s)}
icon={Smile}
directionSentiment={ctx.directionOf("MOOD")}
compareBaseline={ctx.compareBaseline}
compareDelta={ctx.tileCompareDelta(s)}
/>
),
},
];
},
},
{
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: (
<TrendCard
key="sleep"
label={ctx.t("dashboard.sleepShort") ?? "Sleep"}
latest={s?.latest ?? null}
unit="h"
avg7={s?.avg7 ?? null}
avg30={s?.avg30 ?? null}
slope30={s?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(s)}
icon={Moon}
directionSentiment={ctx.directionOf("SLEEP_DURATION")}
compareBaseline={ctx.compareBaseline}
compareDelta={ctx.tileCompareDelta(s)}
staleDays={ctx.tileStaleDays("SLEEP_DURATION")}
// A discreet "sources disagree" marker beside the headline
// when two writers reported clearly different totals for the
// latest night. Null on the ordinary path: no layout change.
valueAdornment={
discrepancy ? (
<SleepSourceDiscrepancyMarker discrepancy={discrepancy} />
) : null
}
/>
),
},
];
},
},
{
// Steps are activity data tied to the workouts module; only an explicit
// `false` hides them (fail-open).
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: (
<TrendCard
key="hrv"
fractionDigits={0}
label={ctx.t(labelKey)}
latest={s?.latest ?? null}
unit="ms"
avg7={s?.avg7 ?? null}
avg30={s?.avg30 ?? null}
slope30={s?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(s)}
icon={HeartPulse}
directionSentiment={ctx.directionOf(type)}
compareBaseline={ctx.compareBaseline}
compareDelta={ctx.tileCompareDelta(s)}
staleDays={ctx.tileStaleDays(type)}
/>
),
},
];
},
},
{
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: (
<TrendCard
key="bpInTarget"
label={bpLabel}
latest={bpSufficient ? (data?.bpInTargetPct ?? null) : null}
emptyHint={
bpSufficient ? null : ctx.t("dashboard.bpInTargetCollecting")
}
unit="%"
avg7={bpSufficient ? bp7 : null}
avg30={bpSufficient ? bp30 : null}
slope30={null}
trend7Delta={bpSufficient ? bpTrendDelta : null}
icon={Target}
directionSentiment={ctx.directionOf("BLOOD_PRESSURE_IN_TARGET")}
compareBaseline={ctx.compareBaseline}
compareDelta={bpSufficient ? bpCompareDelta : null}
/>
),
},
];
},
},
{
// One card per meal-time bucket, kept together in a block.
widget: "glucose",
visible: (ctx) => ctx.glucoseTiles.length > 0,
cards: (ctx) =>
ctx.glucoseTiles.map(({ bucket, labelKey, summary: s }, idx) => {
const unit = ctx.displayGlucoseUnit;
const convert = (v: number | null | undefined) =>
v != null ? convertGlucose(v, unit) : null;
return {
id: `glucose-${bucket}`,
orderOffset: idx / 1000,
node: (
<TrendCard
key={`glucose-${bucket}`}
label={ctx.t(labelKey)}
latest={convert(s.latest)}
unit={unit}
fractionDigits={glucoseFractionDigits(unit)}
avg7={convert(s.avg7)}
avg30={convert(s.avg30)}
slope30={s.slope30 ?? null}
icon={Droplet}
staleDays={ctx.tileStaleDays("BLOOD_GLUCOSE")}
/>
),
};
}),
},
];
/**
* The strip's cards for this render: every tile whose layout toggle is on and
* whose own rule passes, sorted by layout order.
*/
export function buildStripEntries(ctx: DashboardTileContext): StripEntry[] {
const entries: StripEntry[] = [];
for (const tile of STRIP_TILES) {
if (!ctx.isTileVisible(tile.widget) || !tile.visible(ctx)) continue;
const order = ctx.widgetOrder(tile.widget);
for (const card of tile.cards(ctx)) {
entries.push({
id: card.id,
order: order + (card.orderOffset ?? 0),
node: card.node,
});
}
}
entries.sort((a, b) => a.order - b.order);
return entries;
}
@@ -0,0 +1,267 @@
"use client";
import { useMemo } from "react";
import {
pickHrvSummary,
resolveGlucoseTiles,
} from "@/components/dashboard/dashboard-gates";
import type { AuthUser } from "@/hooks/use-auth";
import {
useUnitDisplayOnceMounted,
type UnitDisplay,
} from "@/hooks/use-unit-display";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import type { DataSummary } from "@/lib/analytics/trends";
import {
buildDashboardBands,
viewerBandProfile,
type DashboardTargetBands,
} from "@/lib/dashboard/bands";
import type { DashboardLayout } from "@/lib/dashboard-layout";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
import { toProfileSex } from "@/lib/profile/sex";
import {
resolveWeightTrend,
weightTrendReferenceKg,
} from "@/lib/targets/weight-trend";
import type { DashboardData } from "./use-dashboard-data";
/**
* The resolved bands in the shape the tiles and charts draw them: weight in
* the display unit, blood pressure as the chart's two target zones.
*/
function bandsForDisplay(
bands: DashboardTargetBands,
unitDisplay: UnitDisplay,
t: (key: string) => string,
) {
const weightRange = bands.weightRange;
// The range hint prints its bounds in the display unit, so the kg
// thresholds scale by the weight factor (offset-free). The colour classing
// stays canonical: it compares a canonical average against the canonical
// range.
const weightHintRange = weightRange
? {
greenMin: unitDisplay.toDisplay("WEIGHT", weightRange.greenMin),
greenMax: unitDisplay.toDisplay("WEIGHT", weightRange.greenMax),
orangeMin: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMin),
orangeMax: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMax),
}
: weightRange;
const weightBands = bands.weightBands ?? undefined;
// The kg chart bands scaled into the display unit so the shaded zone
// tracks the converted line (factor only; weight has no offset).
const weightDisplayBands =
weightBands && unitDisplay.preference === "imperial"
? weightBands.map((band) => ({
...band,
min: unitDisplay.toDisplay("WEIGHT", band.min),
max: unitDisplay.toDisplay("WEIGHT", band.max),
}))
: weightBands;
const bpTargets = bands.bpTargets;
const bpTargetZones = bpTargets
? [
{
min: bpTargets.sysLow,
max: bpTargets.sysHigh,
color: "var(--chart-3)",
opacity: 0.21,
label: t("charts.systolic"),
textColor: "var(--chart-3)",
lineOpacity: 0.24,
},
{
min: bpTargets.diaLow,
max: bpTargets.diaHigh,
color: "var(--chart-4)",
opacity: 0.21,
label: t("charts.diastolic"),
textColor: "var(--chart-4)",
lineOpacity: 0.24,
},
]
: undefined;
return {
weightRange,
weightHintRange,
weightDisplayBands,
bpTargetZones,
bpSysRange: bands.bpSysRange,
bpDiaRange: bands.bpDiaRange,
pulseDisplayRange: bands.pulseDisplayRange,
pulseBands: bands.pulseBands,
bodyFatBands: bands.bodyFatBands,
};
}
/**
* What every dashboard tile reads: the summaries, the resolved layout, the
* display units and the target bands, each derived once per render here so a
* tile definition stays a function of this object and nothing else.
*/
export function useDashboardTileContext({
dashboard,
user,
}: {
dashboard: DashboardData;
user: AuthUser | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
// Metric / imperial display choke point. `td` converts an ABSOLUTE
// reading (affine, so a temperature picks up the °F offset); `tdd`
// converts a DELTA or slope (factor only, so a Δ never inherits the
// offset). Metric users take the identity path.
const unitDisplay = useUnitDisplayOnceMounted();
const td = (type: string, value: number | null | undefined): number | null =>
value == null ? null : unitDisplay.toDisplay(type, value);
const tdd = (
type: string,
value: number | null | undefined,
): number | null =>
value == null ? null : unitDisplay.toDisplayDelta(type, value);
const { data, layout, layoutData, moodData, snapshot, snapshotEnabled } =
dashboard;
const summaries = data?.summaries;
const compareBaseline = layout.comparisonBaseline ?? "none";
/**
* The tile delta (current 30-day average minus the prior period's) for a
* summary. Null when comparison is off or either side is missing, so the
* tile suppresses the callout.
*/
const tileCompareDelta = (
summary: DataSummary | null | undefined,
): number | null => {
if (!summary || compareBaseline === "none") return null;
const current = summary.avg30 ?? null;
const prior =
compareBaseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
: (summary.avg30LastYear ?? null);
if (current === null || prior === null) return null;
return Math.round((current - prior) * 100) / 100;
};
/**
* Days since the metric's last reading when that is more than a week, for
* `<TrendCard staleDays>`; null for a fresh metric or one with no reading.
*/
const tileStaleDays = (type: string | null | undefined): number | null => {
if (!type) return null;
const entry = data?.lastSeenByType?.[type];
if (!entry) return null;
return entry.daysAgo > 7 ? entry.daysAgo : null;
};
/** Whether the widget's chart (lower row) shows. */
const isChartVisible = (id: string) =>
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
/**
* Whether the widget's tile in the strip shows. Independent of the chart,
* falling back to the chart flag for layouts saved before the two split.
*/
const isTileVisible = (id: string) => {
const widget = layout.widgets.find((w) => w.id === id);
if (!widget) return false;
return typeof widget.tileVisible === "boolean"
? widget.tileVisible
: widget.visible;
};
const widgetOrder = (id: string) =>
layout.widgets.find((widget) => widget.id === id)?.order ?? 999;
// `layoutData` is undefined until the real layout (snapshot or legacy
// widgets) resolves, and the fallback layout shows cards a person may have
// switched off. Cards gated on the layout alone wait for the real one.
const layoutResolved = layoutData != null;
// Band and target numbers come resolved from the snapshot (`targetBands`).
// The client copy below is computed from the profile through the SAME
// `buildDashboardBands` the server uses, so a still-loading snapshot frame
// keeps real bands rather than blank charts and the two cannot disagree.
// The profile it reads is the caller's, and under a switch the caller is
// the delegate: `viewerBandProfile` then draws no client band at all and
// waits for the snapshot's, which resolve against the record. Keyed on the
// `user` identity so the React Compiler can preserve the memo.
const inSharedRecord = user?.accountAccess?.active != null;
const thresholdsData = dashboard.thresholdsData;
const clientBandProfile = useMemo(
() =>
viewerBandProfile(
{
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
thresholdsJson: thresholdsData?.overrides ?? null,
},
inSharedRecord,
),
[user, thresholdsData, inSharedRecord],
);
const clientBands = useMemo(
() => buildDashboardBands(clientBandProfile),
[clientBandProfile],
);
const serverBands = snapshotEnabled ? snapshot?.targetBands : undefined;
const bands = serverBands ?? clientBands;
// Which way a weight change counts as progress against the stored target.
// The snapshot carries the server's answer; the fallback asks the same
// resolver from the same profile the band fallback reads.
const weight = summaries?.WEIGHT;
const weightTrend =
(snapshotEnabled ? snapshot?.tiles.weightTrend : undefined) ??
resolveWeightTrend(
resolveWeightTargetOverride(clientBandProfile.thresholdsJson),
weightTrendReferenceKg(weight),
);
// Every other tile's direction comes resolved from the server against the
// record's own targets. The snapshot-disabled path has no resolved answer
// and renders neutral rather than guessing a polarity.
const tileDirections = snapshotEnabled
? snapshot?.tiles.directions
: undefined;
const directionOf = (key: string): TrendDirectionSentiment =>
tileDirections?.[key]?.direction ?? "neutral";
// Glucose has no chart slot; the tile flag is its single source of truth.
// Eligibility walks the untagged bucket beside the four named contexts, so
// an account whose source never writes a meal-time tag still sees a tile.
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const glucoseTiles = resolveGlucoseTiles(data?.glucoseByContext ?? {});
return {
t,
fmt,
unitDisplay: unitDisplay as UnitDisplay,
td,
tdd,
user,
data,
summaries,
moodSummary: moodData?.summary,
hrv: pickHrvSummary(summaries),
layout: layout as DashboardLayout,
layoutResolved,
compareBaseline,
tileCompareDelta,
tileStaleDays,
isChartVisible,
isTileVisible,
widgetOrder,
directionOf,
weightTrend,
...bandsForDisplay(bands, unitDisplay, t),
displayGlucoseUnit,
glucoseTiles,
};
}
export type DashboardTileContext = ReturnType<typeof useDashboardTileContext>;
@@ -0,0 +1,288 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { AuthUser } from "@/hooks/use-auth";
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
import { apiGet } from "@/lib/api/api-fetch";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import type { DataSummary } from "@/lib/analytics/trends";
import {
computeLocalBatchWindow,
deriveBatchChartTypes,
type BatchWindow,
} from "@/lib/dashboard/batch-chart-types";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { hideModuleWidgets } from "@/lib/dashboard/widget-modules";
import {
resolveDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDailyDigest } from "@/lib/queries/use-daily-digest";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { queryKeys } from "@/lib/query-keys";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
/**
* Module scope so the option object is a stable reference across renders.
*
* Refetch on window focus: the dashboard is the surface a person leaves open
* in a background tab while changing its tile selection elsewhere; without it
* the stale layout sat visible for up to the poll interval ("saved but nothing
* changed"). `staleTime` bounds the cost to one refetch a minute, and the
* server side is SWR-cached, so a focus flick is a cheap cache hit.
*/
const DASHBOARD_QUERY_OPTS = {
staleTime: 60_000,
refetchOnWindowFocus: true,
} as const;
/** One day of the batched series the dashboard charts read their slice from. */
export interface BatchedSeriesRow {
value: number;
measuredAt: string;
count?: number;
minValue?: number | null;
maxValue?: number | null;
}
export interface DashboardMoodData {
entries: Array<{ date: string; score: number; samples: number }>;
summary: DataSummary;
}
/**
* The tiles' `AnalyticsData` from whichever path feeds them. Snapshot path:
* the one cell, where `extras` is null on a rollup-coverage miss (two-phase
* contract), so the BP-in-target and glucose fields stay undefined and those
* tiles shimmer while the rest of the strip paints. Legacy path: the two
* analytics slices merged.
*/
function assembleDashboardAnalytics(
snapshotEnabled: boolean,
snapshot: ReturnType<typeof useDashboardSnapshot>["data"],
slim: Parameters<typeof mergeSlimAndThickAnalytics>[0],
thick: Parameters<typeof mergeSlimAndThickAnalytics>[1],
): AnalyticsData | undefined {
if (snapshotEnabled) {
const snap = snapshot;
if (!snap) return undefined;
return {
summaries: snap.tiles.summaries,
lastSeenByType: snap.tiles.lastSeenByType,
// Optional on the snapshot (additive contract).
sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null,
bpInTargetPct: snap.extras?.bpInTargetPct ?? null,
bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null,
bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null,
bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null,
bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null,
glucoseByContext: snap.extras?.glucoseByContext as
Record<string, DataSummary> | undefined,
};
}
// `mergeSlimAndThickAnalytics` falls back to the thick slice when the
// slim one resolves with no content, and otherwise lets slim paint first.
const merged = mergeSlimAndThickAnalytics(slim, thick);
if (!merged) return undefined;
return {
summaries: merged.summaries,
lastSeenByType: merged.lastSeenByType,
sleepSourceDiscrepancy: merged.sleepSourceDiscrepancy,
bpInTargetPct: merged.bpInTargetPct,
bpInTargetPct7d: merged.bpInTargetPct7d,
bpInTargetPct30d: merged.bpInTargetPct30d,
bpInTargetPctAllTime: merged.bpInTargetPctAllTime,
bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth,
bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear,
bpInTargetCount90: merged.bpInTargetCount90,
bpInTargetSpanDays90: merged.bpInTargetSpanDays90,
glucoseByContext: merged.glucoseByContext as
Record<string, DataSummary> | undefined,
};
}
/**
* Every read the dashboard's tiles and charts paint from.
*
* The tiles hydrate from ONE un-gated `/api/dashboard/snapshot` cell, so the
* whole strip shares one completion moment and `/api/auth/me` leaves the cold
* critical path. `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` falls back to the
* legacy independent cells (two analytics slices, the widget layout, the mood
* analytics and the user's own thresholds); either way the same
* `AnalyticsData` shape comes out, so no tile knows which path fed it.
*
* The Today hero's digest is read here too, in its original place in the read
* order: it is the day's data, so no module owns it, and it is gated on auth
* only.
*/
export function useDashboardData({
isAuthenticated,
mounted,
user,
batchWindowProp,
}: {
isAuthenticated: boolean;
mounted: boolean;
user: AuthUser | null;
/**
* The batched-series window the RSC prefetch computed from the profile
* time zone. Adopted verbatim so the `chartSeriesBatch` key matches the key
* the server dehydrated under; absent on the prefetch-off and legacy paths,
* where the browser-local window stands in.
*/
batchWindowProp?: BatchWindow;
}) {
const snapshotEnabled = isDashboardSnapshotEnabled();
const snapshotQuery = useDashboardSnapshot(snapshotEnabled);
const digestQuery = useDailyDigest(isAuthenticated);
// Legacy path: the slim slice (`?slice=summaries`) paints every per-type
// tile; the thick slice streams the BP-in-target and glucose fields in
// afterwards. Both share `caches.analytics` server-side.
const analyticsSlimQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !snapshotEnabled && isAuthenticated,
});
const analyticsThickQuery = useAnalyticsQuery({
enabled: !snapshotEnabled && isAuthenticated,
});
const data = useMemo(
() =>
assembleDashboardAnalytics(
snapshotEnabled,
snapshotQuery.data,
analyticsSlimQuery.data,
analyticsThickQuery.data,
),
[
snapshotEnabled,
snapshotQuery.data,
analyticsSlimQuery.data,
analyticsThickQuery.data,
],
);
// The user's own targets, for the legacy (snapshot-disabled) band fallback
// only. In snapshot mode the server resolves the same overrides into
// `targetBands` and this query never fires.
const { data: thresholdsData } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet<{
overrides: Record<string, { min: number; max: number }>;
}>("/api/user/thresholds");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
const { data: layoutDataLegacy } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
return apiGet<DashboardLayout>("/api/dashboard/widgets");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
// The snapshot publishes its layout with switched-off modules' widgets
// already hidden. The legacy feed is the stored layout as saved (Settings
// edits that exact value), so the same mask is applied here, from the same
// surface map, before anything paints.
const layoutData = snapshotEnabled
? snapshotQuery.data?.layout
: layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules);
const moodWidgetVisible = useSurfaceVisible("widget:mood");
const { data: moodDataLegacy } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async () => {
return apiGet<DashboardMoodData>("/api/mood/analytics");
},
// The route refuses with the mood module off; do not ask.
enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible,
...DASHBOARD_QUERY_OPTS,
});
const moodData: DashboardMoodData | undefined = snapshotEnabled
? snapshotQuery.data
? {
entries: snapshotQuery.data.tiles.mood.entries,
summary: (snapshotQuery.data.tiles.mood.summary ??
undefined) as DataSummary,
}
: undefined
: moodDataLegacy;
// Visibility and order of every widget.
const layout = resolveDashboardLayout(layoutData);
// ONE batched fetch for every visible non-sleep chart series; each chart
// reads its slice through `preloadedSeries` instead of self-fetching. Sleep
// stays on its per-night `/series` adapter and is never batched. The type
// set is the shared `deriveBatchChartTypes`, the same function the RSC
// prefetch runs over the same snapshot fields, so the cache key is
// byte-identical on both sides.
const batchChartTypes = deriveBatchChartTypes(layout, data?.summaries);
// A day-bucketed window, computed once per mount (lazy state) so a
// re-render never re-keys the batch. The server-computed window wins when
// present; lazy state with a prop-derived initial value is deterministic
// across the SSR / CSR pair.
const [batchWindow] = useState(
() => batchWindowProp ?? computeLocalBatchWindow(),
);
const { data: batchedSeries } = useQuery({
queryKey: queryKeys.chartSeriesBatch(
batchChartTypes.join(","),
batchWindow.from,
batchWindow.to,
),
enabled: isAuthenticated && mounted && batchChartTypes.length > 0,
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
queryFn: async () => {
const sp = new URLSearchParams();
sp.set("types", batchChartTypes.join(","));
sp.set("from", batchWindow.from);
sp.set("to", batchWindow.to);
const res = await apiGet<{
series: Record<string, BatchedSeriesRow[]>;
}>(`/api/measurements/series-batch?${sp}`);
return res.series;
},
});
// The loading flag follows whichever query actually drives the tiles: in
// snapshot mode the slim query is disabled and reports `isLoading: false`
// forever, which would select the empty state for the whole snapshot
// fetch. `!mounted` pins the SSR pass and the hydration render to the
// skeleton branch (server-side the data queries are disabled, and a late
// hydrating boundary would otherwise render the skeleton where the server
// rendered the empty state: React #418). The hero band and the strip share
// this one gate so both swap to content in the same render pass.
const primaryLoading =
!mounted ||
(snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading);
return {
snapshotEnabled,
snapshot: snapshotQuery.data,
digestQuery,
data,
thresholdsData,
layoutData,
layout,
moodData,
preloadedSeries: batchedSeries,
primaryLoading,
};
}
export type DashboardData = ReturnType<typeof useDashboardData>;
@@ -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).