mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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([]);
|
||||
});
|
||||
|
||||
@@ -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*\{/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
|
||||
import { STRIP_TILES } from "@/components/dashboard/tiles/strip-tiles";
|
||||
import type { DashboardTileContext } from "@/components/dashboard/tiles/tile-context";
|
||||
|
||||
/**
|
||||
* v1.4.19 — guards for the polish pass on `/insights` and the
|
||||
* dashboard:
|
||||
@@ -28,7 +33,6 @@ import { join } from "node:path";
|
||||
*/
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
|
||||
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
|
||||
|
||||
@@ -38,7 +42,7 @@ function load(path: string): string {
|
||||
|
||||
describe("v1.4.19 A3 — dashboard polish", () => {
|
||||
it("dashboard page does NOT render <CompareToggle />", () => {
|
||||
const src = load(DASHBOARD_PATH);
|
||||
const src = dashboardSource();
|
||||
// The component import + JSX use are both gone. The toggle still
|
||||
// lives on /insights via a separate import there.
|
||||
expect(src).not.toMatch(/<CompareToggle\b/);
|
||||
@@ -52,9 +56,9 @@ describe("v1.4.19 A3 — dashboard polish", () => {
|
||||
// baseline plumbing along with the toggle, every chart on the
|
||||
// dashboard would lose its comparison overlay silently. The
|
||||
// resolved layout still carries the value to each chart.
|
||||
const src = load(DASHBOARD_PATH);
|
||||
expect(src).toContain("comparisonBaseline");
|
||||
expect(src).toContain("compareBaseline");
|
||||
const src = dashboardSource();
|
||||
expect(src).toContain("layout.comparisonBaseline");
|
||||
expect(src).toContain("compareBaseline={ctx.compareBaseline}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,13 +207,34 @@ describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta us
|
||||
* window (priorMonth / priorYear) instead of all-time.
|
||||
*/
|
||||
it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => {
|
||||
const src = load(DASHBOARD_PATH);
|
||||
// The fix introduces `bpComparePrior` keyed by `compareBaseline`.
|
||||
expect(src).toContain("bpInTargetPctPriorMonth");
|
||||
expect(src).toContain("bpInTargetPctPriorYear");
|
||||
// The all-time shortcut from v1.4.22 A2 must not subtract `bpAll`
|
||||
// for the comparison delta any more.
|
||||
expect(src).not.toMatch(/bp30\s*-\s*bpAll/);
|
||||
// The tile is built from the registry with a 30-day share of 80 % and
|
||||
// prior periods of 70 % (month), 50 % (year) and 40 % (all time): the
|
||||
// delta follows the chosen baseline and never the all-time share.
|
||||
const tile = STRIP_TILES.find((t) => t.widget === "bpInTarget");
|
||||
const deltaFor = (compareBaseline: string) => {
|
||||
const ctx = {
|
||||
t: (key: string) => key,
|
||||
compareBaseline,
|
||||
directionOf: () => "neutral",
|
||||
data: {
|
||||
bpInTargetPct: 75,
|
||||
bpInTargetPct7d: 78,
|
||||
bpInTargetPct30d: 80,
|
||||
bpInTargetPctAllTime: 40,
|
||||
bpInTargetPctPriorMonth: 70,
|
||||
bpInTargetPctPriorYear: 50,
|
||||
bpInTargetCount90: 200,
|
||||
bpInTargetSpanDays90: 90,
|
||||
},
|
||||
} as unknown as DashboardTileContext;
|
||||
expect(tile?.visible(ctx)).toBe(true);
|
||||
const [card] = tile?.cards(ctx) ?? [];
|
||||
return (card.node as ReactElement<{ compareDelta: number | null }>).props
|
||||
.compareDelta;
|
||||
};
|
||||
expect(deltaFor("lastMonth")).toBe(10);
|
||||
expect(deltaFor("lastYear")).toBe(30);
|
||||
expect(deltaFor("none")).toBeNull();
|
||||
});
|
||||
|
||||
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dashboardSource } from "@/__tests__/helpers/dashboard-source";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -86,10 +87,7 @@ describe("RootLoading — single skeleton source (wiring pins)", () => {
|
||||
join(process.cwd(), "src/app/loading.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const pageSrc = readFileSync(
|
||||
join(process.cwd(), "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const pageSrc = dashboardSource();
|
||||
|
||||
it("imports the same silhouette components the page's loading phase uses", () => {
|
||||
expect(loadingSrc).toContain(
|
||||
|
||||
+96
-2231
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user