diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 90dcfc9bd..95be81295 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -71,6 +71,14 @@ jobs: - run: pnpm exec next build + # Client-bundle budget gate — rides this job because it is the one + # place CI already has a production build on disk. Catches the + # regressions the dead webpack-era tooling (ANALYZE=1, the old + # bundle-report) let through: a statically imported message catalog + # (~100 KB gz on every route) or a chart import bypassing the shared + # chart-runtime boundary (a duplicated ~92 KB gz recharts chunk). + - run: pnpm bundle-check + - run: pnpm exec playwright test env: E2E_BASE_URL: http://localhost:3000 diff --git a/bundle-budget.json b/bundle-budget.json new file mode 100644 index 000000000..80ad488e7 --- /dev/null +++ b/bundle-budget.json @@ -0,0 +1,11 @@ +{ + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.", + "routesKbGz": { + "/page": 460, + "/insights/page": 445, + "/measurements/page": 445, + "/insights/mood/page": 460 + }, + "totalClientKbGz": 2950, + "maxRechartsChunks": 1 +} diff --git a/package.json b/package.json index 6d2ad5b5d..367972474 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "prebuild": "node scripts/generate-sw-version.mjs", "build": "next build", "analyze": "ANALYZE=1 next build", - "bundle-report": "node scripts/print-bundle-report.mjs", + "bundle-report": "node scripts/check-bundle-budget.mjs", + "bundle-check": "node scripts/check-bundle-budget.mjs --check", "start": "next start", "lint": "eslint", "db:generate": "prisma generate", diff --git a/playwright.config.ts b/playwright.config.ts index 1c9773571..43dd69d7b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -88,5 +88,17 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, stdout: "ignore", stderr: "pipe", + env: { + ...process.env, + // The dashboard RSC wrapper server-prefetches the snapshot into + // the first HTML (HydrationBoundary). Playwright's route mocks + // only see CLIENT fetches, so an SSR-embedded snapshot would + // bypass `mockDashboardSnapshot` and every dashboard spec would + // assert against the seeded account instead of its fixture. + // Disable the prefetch for the e2e server — the suite keeps the + // deterministic client-fetch path; the SSR fast path is + // verified by Lighthouse/manual passes. + DASHBOARD_SSR_PREFETCH: "false", + }, }, }); diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs new file mode 100644 index 000000000..713dfb116 --- /dev/null +++ b/scripts/check-bundle-budget.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +/** + * Client-bundle report + budget gate for the Turbopack build. + * + * The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op + * under Turbopack and the old `.next/analyze/client.json` reader had + * nothing to read — so bundle regressions (a statically imported message + * catalog, a duplicated recharts chunk group) shipped invisibly. This + * script derives the signal straight from the build output instead: + * + * - per-route client-JS totals (gzip) from the Turbopack + * `*_client-reference-manifest.js` files plus the shared + * `rootMainFiles` baseline from `build-manifest.json`; + * - the total gzip weight of every emitted client chunk; + * - a recharts-duplication guard: the number of chunks carrying the + * recharts library fingerprint must stay exactly 1 (the shared + * chart-runtime chunk group); + * - a catalog guard: no message-catalog-fingerprinted chunk may be + * referenced by any route's client-reference manifest or by the + * shared baseline (catalogs load only lazily / via /i18n/). + * + * Usage: + * pnpm bundle-report # print the report table + * pnpm bundle-report --check # enforce bundle-budget.json (CI gate) + * + * Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip + * with deliberate headroom over the measured value — the gate exists to + * catch step-change regressions (a ~100 KB catalog, a ~90 KB duplicate + * library), not day-to-day noise. Raise a budget consciously, in the same + * PR that pays the cost, with the reason in the PR body. + */ + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; + +const CHECK = process.argv.includes("--check"); +const BUILD_DIR = join(process.cwd(), ".next"); +const APP_DIR = join(BUILD_DIR, "server", "app"); +const BUDGET_PATH = join(process.cwd(), "bundle-budget.json"); + +if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) { + console.error( + "check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.", + ); + process.exit(2); +} + +/** Recharts library fingerprint (present once in the shared runtime chunk). */ +const RECHARTS_MARK = "CartesianGrid"; +/** + * Message-catalog fingerprint. The key set is identical across all six + * locales, so a KEY-position match ("typeTimeInDaylight" directly followed + * by a colon, in either the plain or the JSON.parse-escaped chunk encoding) + * marks a bundled catalog. Source modules only ever carry the namespaced + * call-site form (`measurements.typeTimeInDaylight`), which matches neither. + */ +const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":']; + +const sizeCache = new Map(); +function chunkInfo(file) { + if (!sizeCache.has(file)) { + try { + const buf = readFileSync(join(BUILD_DIR, file)); + sizeCache.set(file, { + raw: buf.length, + gz: gzipSync(buf, { level: 9 }).length, + recharts: buf.includes(RECHARTS_MARK), + catalog: CATALOG_MARKS.some((m) => buf.includes(m)), + }); + } catch { + sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false }); + } + } + return sizeCache.get(file); +} + +function routeChunks(manifestFile) { + const src = readFileSync(manifestFile, "utf8"); + const idx = src.indexOf("= {"); + const json = JSON.parse(src.slice(idx + 2).replace(/;\s*$/, "")); + const files = new Set(); + for (const key of Object.keys(json.clientModules ?? {})) { + for (const chunk of json.clientModules[key].chunks ?? []) { + if (chunk.endsWith(".js")) files.add(chunk.replace(/^\/_next\//, "")); + } + } + return files; +} + +function findRouteManifests(dir, prefix = "") { + let out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`)); + } else if (entry.name.endsWith("_client-reference-manifest.js")) { + out.push({ + route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`, + file: p, + }); + } + } + return out; +} + +const kb = (bytes) => bytes / 1024; +const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`; + +// ── Shared baseline ───────────────────────────────────────────────────────── +const buildManifest = JSON.parse( + readFileSync(join(BUILD_DIR, "build-manifest.json"), "utf8"), +); +const rootFiles = buildManifest.rootMainFiles ?? []; +let rootGz = 0; +for (const f of rootFiles) rootGz += chunkInfo(f).gz; + +// ── Per-route totals ──────────────────────────────────────────────────────── +const manifests = findRouteManifests(APP_DIR); +const routeTotals = new Map(); +for (const { route, file } of manifests) { + let gz = 0; + let catalogRef = false; + for (const chunk of routeChunks(file)) { + const info = chunkInfo(chunk); + gz += info.gz; + if (info.catalog) catalogRef = true; + } + routeTotals.set(route, { gz: gz + rootGz, catalogRef }); +} + +// ── Whole-build chunk scan ────────────────────────────────────────────────── +const chunkDir = join(BUILD_DIR, "static", "chunks"); +let totalGz = 0; +let rechartsChunks = 0; +let largest = { file: "-", gz: 0 }; +for (const f of readdirSync(chunkDir)) { + if (!f.endsWith(".js")) continue; + const info = chunkInfo(join("static", "chunks", f)); + totalGz += info.gz; + if (info.recharts) rechartsChunks += 1; + if (info.gz > largest.gz) largest = { file: f, gz: info.gz }; +} +const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog); + +// ── Report ────────────────────────────────────────────────────────────────── +const budget = existsSync(BUDGET_PATH) + ? JSON.parse(readFileSync(BUDGET_PATH, "utf8")) + : null; + +console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`); +console.log(`all client chunks: ${fmt(totalGz)} gz`); +console.log(`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`); +console.log(`recharts-fingerprint chunks: ${rechartsChunks}`); +console.log(""); + +const watched = budget ? Object.keys(budget.routesKbGz ?? {}) : []; +const rows = [...routeTotals.entries()] + .filter(([route]) => !watched.length || watched.includes(route)) + .sort((a, b) => b[1].gz - a[1].gz); +for (const [route, { gz }] of rows.slice(0, watched.length || 15)) { + const cap = budget?.routesKbGz?.[route]; + console.log( + `${fmt(gz).padStart(8)} gz ${route}${cap ? ` (budget ${cap} KB)` : ""}`, + ); +} + +if (!CHECK) process.exit(0); + +// ── Budget gate ───────────────────────────────────────────────────────────── +if (!budget) { + console.error("check-bundle-budget: bundle-budget.json missing."); + process.exit(2); +} + +const failures = []; +for (const [route, capKb] of Object.entries(budget.routesKbGz ?? {})) { + const actual = routeTotals.get(route); + if (!actual) { + failures.push(`route ${route} not found in the build output`); + continue; + } + if (kb(actual.gz) > capKb) { + failures.push( + `${route}: ${fmt(actual.gz)} gz exceeds the ${capKb} KB budget`, + ); + } +} +if (budget.totalClientKbGz && kb(totalGz) > budget.totalClientKbGz) { + failures.push( + `total client JS ${fmt(totalGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`, + ); +} +if ( + budget.maxRechartsChunks != null && + rechartsChunks > budget.maxRechartsChunks +) { + failures.push( + `${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`, + ); +} +const catalogRefRoutes = [...routeTotals.entries()] + .filter(([, v]) => v.catalogRef) + .map(([r]) => r); +if (catalogRefRoutes.length > 0 || rootCatalogRef) { + failures.push( + `message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/ + dynamic import)`, + ); +} + +if (failures.length > 0) { + console.error("\nBundle budget check FAILED:"); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +console.log("\nBundle budget check passed."); diff --git a/scripts/print-bundle-report.mjs b/scripts/print-bundle-report.mjs deleted file mode 100644 index 9b5af43a2..000000000 --- a/scripts/print-bundle-report.mjs +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env node -/** - * Reads `.next/analyze/client.json` (emitted when `pnpm analyze` runs - * `@next/bundle-analyzer` with `ANALYZE=1`) and prints a sorted table - * of the largest client chunks. Turbopack dropped the webpack - * "First Load JS" summary line from `next build`; this script restores - * an equivalent at-a-glance signal so release rounds can spot - * client-bundle regressions without re-running Lighthouse. - * - * Usage: - * pnpm analyze # writes .next/analyze/client.json - * pnpm bundle-report # reads it back, prints top chunks - * - * Output columns: rank, chunk label, parsed (post-minify) size, gzip - * size, stat (pre-minify) size. Sizes report in KiB to two decimals. - * Exits 0 unless `client.json` is missing or unreadable. - */ - -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; - -const REPORT_PATH = resolve(process.cwd(), ".next", "analyze", "client.json"); -const DEFAULT_TOP_N = 10; - -function formatKib(bytes) { - if (typeof bytes !== "number" || Number.isNaN(bytes)) return "—"; - return `${(bytes / 1024).toFixed(2)} KiB`; -} - -function padCell(value, width) { - const str = String(value ?? ""); - return str.length >= width ? str : str + " ".repeat(width - str.length); -} - -async function main() { - const argTopN = Number(process.argv[2]); - const topN = - Number.isFinite(argTopN) && argTopN > 0 - ? Math.floor(argTopN) - : DEFAULT_TOP_N; - - let raw; - try { - raw = await readFile(REPORT_PATH, "utf8"); - } catch (err) { - if (err && err.code === "ENOENT") { - console.error( - `bundle-report: ${REPORT_PATH} not found. Run \`pnpm analyze\` first.`, - ); - } else { - console.error(`bundle-report: failed to read ${REPORT_PATH}:`, err); - } - process.exit(1); - } - - let chunks; - try { - chunks = JSON.parse(raw); - } catch (err) { - console.error(`bundle-report: failed to parse ${REPORT_PATH}:`, err); - process.exit(1); - } - - if (!Array.isArray(chunks)) { - console.error( - "bundle-report: client.json is not an array of chunks — analyzer output shape changed?", - ); - process.exit(1); - } - - const sorted = [...chunks].sort( - (a, b) => (b.parsedSize ?? 0) - (a.parsedSize ?? 0), - ); - const top = sorted.slice(0, topN); - - const totals = chunks.reduce( - (acc, c) => ({ - parsed: acc.parsed + (c.parsedSize ?? 0), - gzip: acc.gzip + (c.gzipSize ?? 0), - stat: acc.stat + (c.statSize ?? 0), - }), - { parsed: 0, gzip: 0, stat: 0 }, - ); - - const labelWidth = Math.max( - 20, - ...top.map((c) => String(c.label ?? "").length), - ); - const header = - `${padCell("#", 3)} ${padCell("chunk", labelWidth)} ` + - `${padCell("parsed", 12)} ${padCell("gzip", 12)} ${padCell("stat", 12)}`; - - console.log( - `Top ${top.length} client chunks by parsed size (of ${chunks.length} total):`, - ); - console.log(header); - console.log("-".repeat(header.length)); - top.forEach((chunk, idx) => { - console.log( - `${padCell(idx + 1, 3)} ${padCell(chunk.label ?? "(unnamed)", labelWidth)} ` + - `${padCell(formatKib(chunk.parsedSize), 12)} ` + - `${padCell(formatKib(chunk.gzipSize), 12)} ` + - `${padCell(formatKib(chunk.statSize), 12)}`, - ); - }); - console.log("-".repeat(header.length)); - console.log( - `${padCell("Σ", 3)} ${padCell("all chunks", labelWidth)} ` + - `${padCell(formatKib(totals.parsed), 12)} ` + - `${padCell(formatKib(totals.gzip), 12)} ` + - `${padCell(formatKib(totals.stat), 12)}`, - ); -} - -main().catch((err) => { - console.error("bundle-report: unexpected failure:", err); - process.exit(1); -}); diff --git a/src/app/__tests__/dashboard-analytics-split.test.ts b/src/app/__tests__/dashboard-analytics-split.test.ts index 5ddaa2115..d595bab17 100644 --- a/src/app/__tests__/dashboard-analytics-split.test.ts +++ b/src/app/__tests__/dashboard-analytics-split.test.ts @@ -27,7 +27,7 @@ import { join } from "node:path"; * holds; the matchers only relaxed to tolerate the `enabled` arg. */ const ROOT = join(__dirname, "../../.."); -const PAGE_PATH = join(ROOT, "src/app/page.tsx"); +const PAGE_PATH = join(ROOT, "src/app/page-client.tsx"); function load(path: string): string { return readFileSync(path, "utf8"); diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx index 3a3a44700..454c7dc9c 100644 --- a/src/app/__tests__/dashboard-chart-reveal.test.tsx +++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx @@ -34,7 +34,7 @@ import { import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; import { I18nProvider } from "@/lib/i18n/context"; -const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); +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)", () => { diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index c5f562cd0..27c92c640 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -19,7 +19,7 @@ import { join } from "node:path"; * layout toggle) the header takes it back behind `showGreeting`, * fed from the page's hero gate — the greeting never disappears. */ -const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); +const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx"); const HEADER_PATH = join( process.cwd(), "src/components/dashboard/dashboard-header.tsx", diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 30c4dee8d..d2d04e496 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -23,7 +23,7 @@ import { join } from "node:path"; * rather than silently regressing the streaming-composition contract. */ const ROOT = join(__dirname, "../../.."); -const PAGE_PATH = join(ROOT, "src/app/page.tsx"); +const PAGE_PATH = join(ROOT, "src/app/page-client.tsx"); function load(path: string): string { return readFileSync(path, "utf8"); diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 95d1926f2..2c9828023 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -6,7 +6,7 @@ import { resolveChartRowPlaceholderCount, resolveConfiguredTileCount, resolveDashboardFirstPaintGate, -} from "../page"; +} from "../page-client"; import { DEFAULT_DASHBOARD_LAYOUT, type DashboardLayout, @@ -33,7 +33,7 @@ import { * snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the * behaviour; textual pins keep the wiring honest. */ -const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); +const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx"); describe("resolveDashboardFirstPaintGate", () => { const base = { diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index 1b44d387b..cdb7063d3 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -28,7 +28,7 @@ import { join } from "node:path"; */ const ROOT = join(__dirname, "../../.."); -const DASHBOARD_PATH = join(ROOT, "src/app/page.tsx"); +const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx"); const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx"); const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx"); diff --git a/src/app/__tests__/root-loading-silhouette.test.tsx b/src/app/__tests__/root-loading-silhouette.test.tsx index 77b8b9598..ce7eb0488 100644 --- a/src/app/__tests__/root-loading-silhouette.test.tsx +++ b/src/app/__tests__/root-loading-silhouette.test.tsx @@ -86,7 +86,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.tsx"), "utf8"); + const pageSrc = readFileSync(join(process.cwd(), "src/app/page-client.tsx"), "utf8"); it("imports the same silhouette components the page's loading phase uses", () => { expect(loadingSrc).toContain( diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index edc86ed69..40278e63a 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -29,34 +29,14 @@ * read-only from `User.insightsCachedText`. The nightly * `insight-pregenerate` cron keeps that cache warm. */ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; -import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; -import { - buildDashboardSnapshot, - type DashboardSnapshot, - type SnapshotUserInput, -} from "@/lib/dashboard/snapshot"; -import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; export const dynamic = "force-dynamic"; -/** - * Per-key TTL for the snapshot cache entry. Strictly greater than the - * client's 120 s refetch interval so a scheduled poll lands on a warm - * entry instead of a guaranteed miss that re-runs the full builder. The - * 60 s headroom absorbs interval jitter / a poll firing a touch late. - * The analytics bucket's 60 s default still governs the slim / thick / - * mood cells; only this key is lengthened. Eviction on writes is - * unchanged (the `${userId}|` prefix sweep / point-delete both ignore - * the TTL). - */ -const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; - export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "dashboard.snapshot" } }); @@ -72,45 +52,10 @@ export const GET = apiHandler(async () => { return result; }; - // `requireAuth().user` is the full Prisma `User` row, so every field - // the builder needs is already present — no extra round-trip. - const snapshotUser: SnapshotUserInput = { - id: user.id, - username: user.username, - displayName: user.displayName, - timezone: user.timezone, - heightCm: user.heightCm, - dateOfBirth: user.dateOfBirth, - gender: user.gender, - glucoseUnit: user.glucoseUnit, - onboardingTourCompleted: user.onboardingTourCompleted, - disableCoach: user.disableCoach, - insightsCachedText: user.insightsCachedText, - insightsCachedAt: user.insightsCachedAt, - dashboardWidgetsJson: user.dashboardWidgetsJson, - }; - - // v1.21.2 (A4) — the briefing recall + forward-look is locale-specific prose, - // so the snapshot cache key carries the resolved locale: an EN and a DE - // session never share a snapshot cell (and never see each other's memory - // wording). The `${user.id}|` prefix still covers the key under the - // measurement-write stale-sweep. - const locale = await resolveServerLocale({ userLocale: user.locale }); - - // Stale-while-revalidate: a measurement write marks the analytics - // bucket stale rather than hard-evicting the snapshot, so a busy iOS - // sync serves the prior snapshot immediately (within the bucket's - // stale window) while a single background recompute warms a fresh one - // — the foreground request never pays the cold rebuild. Hard-evicting - // writes (widget reorder) still drop the key outright, forcing a clean - // miss + synchronous rebuild here. - const body = await cachedSwr( - caches.analytics as ServerCache, - `${user.id}|dashboard-snapshot|${locale}`, - () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), - annotate, - SNAPSHOT_CACHE_TTL_MS, - ); + // The cached read (user-row mapping, locale resolution, SWR cell) is + // shared with the dashboard RSC prefetch — see + // `src/lib/dashboard/snapshot-read.ts` for the cache-key/TTL contract. + const { body } = await readDashboardSnapshotCached(user, time); // Only surface timings on the cache-miss path (the hit path skips the // whole builder and leaves `timings` empty). diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx new file mode 100644 index 000000000..0baf2878c --- /dev/null +++ b/src/app/page-client.tsx @@ -0,0 +1,1727 @@ +"use client"; + +import React, { Suspense, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { + Activity, + Droplet, + Footprints, + Gauge, + Heart, + Moon, + Percent, + Plus, + Smile, + Target, + TrendingUp, +} from "lucide-react"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { cn } from "@/lib/utils"; +import { + resolveDashboardLayout, + type DashboardLayout, +} from "@/lib/dashboard-layout"; +import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; +import dynamic from "next/dynamic"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { importWithRetry } from "@/lib/retry-import"; +import { + DashboardChartCell, + useDashboardChartReveal, +} from "@/components/dashboard/chart-reveal"; +import { DashboardHeader } from "@/components/dashboard/dashboard-header"; +import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero"; +import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton"; +import { + QuickEntrySheets, + type QuickEntryDialog, +} from "@/components/dashboard/quick-entry-sheets"; +import { + getRangeColorClass, + getRangeHint, + toHoursSummary, +} from "@/components/dashboard/range-display"; +import { TrendCard } from "@/components/charts/trend-card"; +import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; +import { TrendHint } from "@/components/charts/trend-hint"; +import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; +import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; +import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; +import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; +import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; + +// v1.4.40 W-RSC — module-scope so the option object is stable across +// renders (audit-M2). Pre-fix the same literal was declared inside the +// component body, so every render created a fresh `{}` reference; +// TanStack does a shallow compare and shrugs at the equivalent values, +// but the slot is one future-callback-field away from cache poisoning. +const DASHBOARD_QUERY_OPTS = { + staleTime: 60_000, + refetchOnWindowFocus: false, +} as const; + +// v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches. +// Threaded to every chart as `preloadedCoverageDays` so a chart reads the +// batched slice ONLY for a range tab whose window fits within it (7 / 30); +// the 90 / All tabs exceed it and self-fetch the wider window. Pre-fix the +// chart trusted the batch for any window > 7 days, so 90 / All silently +// re-rendered this ~30-day slice. Keep this in lockstep with `batchWindow`. +const BATCH_COVERAGE_DAYS = 31; + +// v1.16.8 — the loaders retry a rejected chunk import once (a lazy +// import caches its rejection permanently, so a transient 404 from a +// stale shell used to brick the card for the session) and each mount +// wraps in `` so a chunk that still fails degrades +// to ONE error card instead of bubbling to the route-level `error.tsx`. +const MoodChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.MoodChart }), + ), + { ssr: false, loading: () => }, +); +const MoodChart = (props: React.ComponentProps) => ( + + + +); +const MedicationComplianceChartLazy = dynamic( + () => + importWithRetry( + () => import("@/components/charts/chart-runtime"), + ).then((mod) => ({ default: mod.MedicationComplianceChart })), + { ssr: false, loading: () => }, +); +const MedicationComplianceChart = ( + props: React.ComponentProps, +) => ( + + + +); +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; +import { isWindowSufficient } from "@/lib/analytics/window-confidence"; +import { buildDashboardBands } from "@/lib/dashboard/bands"; +import { apiGet } from "@/lib/api/api-fetch"; + +// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to +// `@/components/dashboard/dashboard-gates`. Re-exported here so the +// existing `../page` unit-test imports stay valid while `page.tsx` thins +// to an orchestrator. +export { + resolveDashboardFirstPaintGate, + resolveConfiguredTileCount, + resolveChartRowPlaceholderCount, +} from "@/components/dashboard/dashboard-gates"; +import { + resolveDashboardFirstPaintGate, + resolveConfiguredTileCount, + resolveChartRowPlaceholderCount, +} from "@/components/dashboard/dashboard-gates"; + +export default function DashboardPageClient() { + const { isAuthenticated, user } = useAuth(); + const mounted = useMounted(); + const { t } = useTranslations(); + const fmt = useFormatters(); + const [quickEntryDialog, setQuickEntryDialog] = + useState(null); + // v1.4.29 M5 — the three inline dashboard queries default to a + // 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch + // storm. None of these are real-time data; minute-scale staleness + // is fine and the dashboard's chart queries already match this + // cadence. + // + // v1.4.40 W-RSC — `DASHBOARD_QUERY_OPTS` hoisted to module scope + // (audit-M2 — stable reference across renders). + + // v1.4.39.2 — split the dashboard's analytics consumption so the + // tile-strip paints from the slim slice and the BD-Zielbereich + + // glucose tiles stream in from the thick slice afterwards. + // + // Pre-fix: a single `useAnalyticsQuery()` against `/api/analytics` + // (thick slice) blocked every per-type tile until the heavy fan-out + // resolved. Mood and medication tiles paint from their own dedicated + // endpoints and arrived first; the per-type measurement tiles then + // arrived as one burst once the full slice landed — the maintainer reported + // this "blocked-then-burst" pattern as "etwas nervig". + // + // Post-fix: two parallel queries. The slim slice (`?slice=summaries`) + // resolves the per-type DataSummary headlines + `lastSeenByType` and + // typically returns in well under a second once the rollup tier is + // warm; that paints every per-type tile in the strip. The thick + // slice resolves `bpInTargetPct*` + `glucoseByContext` for the + // BD-Zielbereich and glucose tiles, which stream in independently. + // Both queries share `caches.analytics` server-side so warm hits + // stay free, and TanStack's parallel-mounting keeps the network fan- + // out flat. + // Unified first-paint snapshot (reversible rollout flag, default ON). + // Every tile hydrates from ONE un-gated `/api/dashboard/snapshot` cell + // so the whole strip shares one completion moment and the + // `/api/auth/me` round-trip leaves the cold critical path. Set + // `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy + // four independent cells. + const snapshotEnabled = isDashboardSnapshotEnabled(); + const snapshotQuery = useDashboardSnapshot(snapshotEnabled); + + const analyticsSlimQuery = useAnalyticsQuery({ + slice: "summaries", + enabled: !snapshotEnabled && isAuthenticated, + }); + const analyticsThickQuery = useAnalyticsQuery({ + enabled: !snapshotEnabled && isAuthenticated, + }); + const data = useMemo(() => { + // v1.7.0 W6 — snapshot path: assemble the same `AnalyticsData` + // shape from the single snapshot cell so every downstream tile + // reads unchanged. `extras` is null on a rollup-coverage miss + // (two-phase contract) → the BD-Zielbereich + glucose fields stay + // undefined and those tiles render their per-tile shimmer while the + // rest of the strip paints. + if (snapshotEnabled) { + const snap = snapshotQuery.data; + if (!snap) return undefined; + return { + summaries: snap.tiles.summaries, + lastSeenByType: snap.tiles.lastSeenByType, + bpInTargetPct: snap.extras?.bpInTargetPct ?? null, + bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null, + bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null, + bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null, + bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null, + bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null, + bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null, + bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null, + glucoseByContext: snap.extras?.glucoseByContext as + Record | undefined, + }; + } + // v1.4.39.3 — the merge moved to `mergeSlimAndThickAnalytics` so + // the empty-slim-vs-populated-thick edge has direct unit + // coverage. Pre-fix the inline `slim?.summaries ?? thick?.summaries` + // short-circuited on a truthy-but-empty `{}` from the slim slice + // and blanked the tile strip even when thick carried the full + // payload — the regression the maintainer's v1.4.39.3 e2e CI flagged across + // eight dashboard / chart specs. The helper falls back to thick + // when slim resolves with no content and otherwise keeps the + // v1.4.39.2 slim-wins-first progressive-paint contract. + const merged = mergeSlimAndThickAnalytics( + analyticsSlimQuery.data, + analyticsThickQuery.data, + ); + if (!merged) return undefined; + return { + summaries: merged.summaries, + lastSeenByType: merged.lastSeenByType, + bpInTargetPct: merged.bpInTargetPct, + bpInTargetPct7d: merged.bpInTargetPct7d, + bpInTargetPct30d: merged.bpInTargetPct30d, + bpInTargetPctAllTime: merged.bpInTargetPctAllTime, + bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth, + bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear, + bpInTargetCount90: merged.bpInTargetCount90, + bpInTargetSpanDays90: merged.bpInTargetSpanDays90, + glucoseByContext: merged.glucoseByContext as + Record | undefined, + }; + }, [ + snapshotEnabled, + snapshotQuery.data, + analyticsSlimQuery.data, + analyticsThickQuery.data, + ]); + + const { data: layoutDataLegacy } = useQuery({ + queryKey: queryKeys.dashboardWidgets(), + queryFn: async () => { + return apiGet("/api/dashboard/widgets"); + }, + enabled: !snapshotEnabled && isAuthenticated, + ...DASHBOARD_QUERY_OPTS, + }); + const layoutData = snapshotEnabled + ? snapshotQuery.data?.layout + : layoutDataLegacy; + + const { data: moodDataLegacy } = useQuery({ + queryKey: queryKeys.moodAnalytics(), + queryFn: async () => { + return apiGet<{ + entries: Array<{ date: string; score: number; samples: number }>; + summary: DataSummary; + }>("/api/mood/analytics"); + }, + enabled: !snapshotEnabled && isAuthenticated, + ...DASHBOARD_QUERY_OPTS, + }); + const moodData = snapshotEnabled + ? snapshotQuery.data + ? { + entries: snapshotQuery.data.tiles.mood.entries, + summary: (snapshotQuery.data.tiles.mood.summary ?? + undefined) as DataSummary, + } + : undefined + : moodDataLegacy; + + // v1.4.27 B1 — the dashboard's `` retired (it + // duplicated the much-richer `/insights` advisor surface). The advisor + // query lives on `/insights` directly; the dashboard no longer needs + // its own hook subscription, so the local `useInsightsAdvisorQuery` + // call dropped with the preview. + + // v1.4.27 — per-tile availability gates live a few lines below as the + // existing `hasWeight` / `hasBp` / `hasPulse` / `hasBodyFat` / `hasMood` / + // `hasSleep` / `hasSteps` flags. They mirror `hasMetricData` from + // `src/lib/insights/metric-availability.ts` for the routed Insights + // surfaces — both branches read `summaries[METRIC].count > 0`. + + const w = data?.summaries?.WEIGHT; + const sys = data?.summaries?.BLOOD_PRESSURE_SYS; + const dia = data?.summaries?.BLOOD_PRESSURE_DIA; + const p = data?.summaries?.PULSE; + // v1.15.12 A2 — the resting-pulse target band is judged against the + // RESTING_HEART_RATE series (Apple's clean daily resting figure), not + // raw PULSE which mixes in workout HR. When the user has resting rows, + // the pulse tile shows the resting figure + the resting-band colour; + // when only raw PULSE exists, the tile shows heart rate WITHOUT the + // resting-band colour overlay (no "outside target" over workout HR). + const rhr = data?.summaries?.RESTING_HEART_RATE; + const hasRestingHr = (rhr?.count ?? 0) > 0; + const pulseTileSummary = hasRestingHr ? rhr : p; + const bf = data?.summaries?.BODY_FAT; + const sleepSummary = data?.summaries?.SLEEP_DURATION; + // v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep + // totals in MINUTES (the server collapses the per-stage rows into one + // night value; see `summaries-slice.ts`). The sleep tile renders hours, + // so convert every value field minutes→hours here while keeping the + // staleness / count metadata untouched. This is the web-parity twin of + // the iOS dashboard-summary route which already emits `unit:"h"`. + const sleepSummaryHours = sleepSummary + ? toHoursSummary(sleepSummary) + : undefined; + const stepsSummary = data?.summaries?.ACTIVITY_STEPS; + // v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics + // auto-populates this summary because the route iterates over the + // full measurementTypeEnum.options list; no backend change needed. + const vo2Summary = data?.summaries?.VO2_MAX; + const moodSummary = moodData?.summary; + + // Resolve full dashboard layout — controls visibility + order of every widget + const layout = resolveDashboardLayout(layoutData); + /** + * v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) read + * from the resolved layout so every chart + tile on the dashboard + * receives the same value. "none" = comparison off (pre-B8 default). + */ + const compareBaseline = layout.comparisonBaseline ?? "none"; + + /** + * v1.4.16 phase B8 — derive the tile-delta value (current 30d avg + * minus prior-period 30d avg) for any DataSummary. Returns null + * when comparison is off OR either side is missing data so the + * tile can suppress the callout cleanly. + */ + const tileCompareDelta = ( + summary: DataSummary | null | undefined, + ): number | null => { + if (!summary || compareBaseline === "none") return null; + const current = summary.avg30 ?? null; + const prior = + compareBaseline === "lastMonth" + ? (summary.avg30LastMonth ?? null) + : (summary.avg30LastYear ?? null); + if (current === null || prior === null) return null; + return Math.round((current - prior) * 100) / 100; + }; + + /** + * v1.4.34 IW-B — read the per-type freshness map and surface the + * `daysAgo` value when the metric is older than a week. The + * tile-strip below forwards the result to `` + * which picks the bucket-aware copy (Xd / X weeks / X months) and + * paints the caption on the tile. Returns `null` for metrics with no + * reading yet OR within the fresh window so call sites stay + * undefined-safe and tiles with recent data paint byte-identical + * with the pre-v1.4.34 contract. + */ + const tileStaleDays = (type: string | null | undefined): number | null => { + if (!type) return null; + const entry = data?.lastSeenByType?.[type]; + if (!entry) return null; + return entry.daysAgo > 7 ? entry.daysAgo : null; + }; + /** Whether the widget's *chart* (lower row) shows. */ + const isChartVisible = (id: string) => + layout.widgets.find((widget) => widget.id === id)?.visible ?? false; + /** + * v1.4.15 Fix 5 — whether the widget's *tile* in the strip shows. + * Independent of the chart visibility so the user can hide chart but + * keep the tile (or vice versa) from Settings → Dashboard. Falls back + * to chart visibility for layouts saved before v1.4.15. + */ + const isTileVisible = (id: string) => { + const widget = layout.widgets.find((w) => w.id === id); + if (!widget) return false; + return typeof widget.tileVisible === "boolean" + ? widget.tileVisible + : widget.visible; + }; + const widgetOrder = (id: string) => + layout.widgets.find((widget) => widget.id === id)?.order ?? 999; + + // Data-floor gates (widget shows iff visible AND has data) + const hasWeight = (w?.count ?? 0) > 0; + const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0; + // v1.15.12 A2 — the pulse tile shows resting HR when available, else + // raw PULSE; either signal having data shows the tile. + const hasPulse = (p?.count ?? 0) > 0 || (rhr?.count ?? 0) > 0; + const hasBodyFat = (bf?.count ?? 0) > 0; + const hasMood = (moodSummary?.count ?? 0) > 0; + const hasSleep = (sleepSummary?.count ?? 0) > 0; + const hasSteps = (stepsSummary?.count ?? 0) > 0; + const hasVo2 = (vo2Summary?.count ?? 0) > 0; + /** + * v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %" + * placeholder doesn't ride on the dashboard when none of the user's + * paired readings sit inside the target band. The historical + * behaviour (`!= null`) painted the tile whenever the analytics + * route emitted a numeric percentage, including the legitimate-but- + * misleading zero. The corrected gate requires at least one window + * (7d, 30d, all-time) to report a non-zero share. When every + * window is zero, the tile is hidden — the user sees the BP charts + * + the Insights blood-pressure target panel for the deeper analysis + * instead. + * The audit's F4 reproduction sat on 540 BP samples with all sub- + * windows reading 0 % because the seed data straddled the target + * ceiling; the tile keeps its place once even one sub-window + * crosses zero. + */ + const hasBpInTarget = + data?.bpInTargetPct != null && + [ + data?.bpInTargetPct, + data?.bpInTargetPct7d, + data?.bpInTargetPct30d, + data?.bpInTargetPctAllTime, + ].some((pct) => pct != null && pct > 0); + + // Tile (strip) gates — controlled by the new `tileVisible` flag. + const showWeightTile = isTileVisible("weight") && hasWeight; + const showBpTiles = isTileVisible("bp") && hasBp; + const showPulseTile = isTileVisible("pulse") && hasPulse; + const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat; + const showMoodTile = isTileVisible("mood") && hasMood; + const showSleepTile = isTileVisible("sleep") && hasSleep; + // v1.18.0 B1 — steps are activity data tied to the workouts module + // surface; AND the resolved module flag (fail-open: only an explicit + // `false` hides it) so a disabled-workouts account drops the tile. + const showStepsTile = + isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false; + const showVo2Tile = isTileVisible("vo2Max") && hasVo2; + const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; + + // Chart (lower row) gates — controlled by the legacy `visible` flag. + const showWeightChart = isChartVisible("weight") && hasWeight; + const showBpCharts = isChartVisible("bp") && hasBp; + const showPulseChart = isChartVisible("pulse") && hasPulse; + const showBodyFatChart = isChartVisible("bodyFat") && hasBodyFat; + const showMoodChart = isChartVisible("mood") && hasMood; + const showSleepChart = isChartVisible("sleep") && hasSleep; + const showStepsChart = isChartVisible("steps") && hasSteps; + const showMedicationsCard = isChartVisible("medications"); + // v1.18.2 — Vorsorge summary card on the chart row (opt-in). Always-on + // data surface, so it gates on the layout toggle alone. + const showVorsorgeCard = isChartVisible("vorsorge"); + // `layoutData` is undefined until the real layout (snapshot or legacy + // widgets) resolves; `resolveDashboardLayout(undefined)` falls back to + // DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default. + // Gating layout-toggle-only cards on that fallback flashed them in for + // the load window and then retracted them for any user who had turned + // the widget OFF (their real layout arriving after first paint). The + // data-driven tiles tolerate the fallback because they also gate on a + // data floor; the achievements + recent-workouts cards have no floor, + // so wait for the real layout before committing them. + const layoutResolved = layoutData != null; + // v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself + // self-handles the loading skeleton + empty state (CTA → /achievements), + // so we only need the layout-toggle gate here. No data-floor check (the + // empty card is intentional — the maintainer wants the user to discover + // the feature). + const showAchievementsCard = layoutResolved && isChartVisible("achievements"); + // v1.4.32 — recent workouts dashboard tile. Self-gates on the + // workouts query response so we only need the layout toggle here; + // the tile renders an Apple-Health-onboarding hint when empty. Same + // layout-resolved gate as the achievements card — default-visible with + // no data floor, so it would otherwise flash in then retract for a user + // who hid it. + const showRecentWorkoutsTile = + layoutResolved && + isChartVisible("recentWorkouts") && + user?.modules?.workouts !== false; + + // v1.16.0 — shared reveal gate for the chart row. Every data-backed + // chart mounts as soon as its visibility gate flips (so the per-chart + // queries fan out in parallel), but the cells hold their layout-stable + // skeletons until EVERY gated chart reported its data settled — or the + // 2 s timeout fires so one slow widget cannot block the row (see + // `chart-reveal.tsx`). Pre-fix the cheap `/api/mood/analytics` read + // made the mood chart paint first and the measurement charts trickle + // in one after another. The id list mirrors the `charts[]` entry ids + // below; the achievements + recent-workouts cards stay outside the + // gate (they self-skeleton and carry no chart-shaped footprint). + const revealChartIds: string[] = []; + if (showWeightChart) { + revealChartIds.push("weight-chart"); + if (user?.heightCm) revealChartIds.push("bmi-chart"); + } + if (showBpCharts) revealChartIds.push("bp-chart"); + if (showPulseChart) revealChartIds.push("pulse-chart"); + if (showBodyFatChart) revealChartIds.push("bodyFat-chart"); + if (showMoodChart) revealChartIds.push("mood-chart"); + if (showSleepChart) revealChartIds.push("sleep-chart"); + if (showStepsChart) revealChartIds.push("steps-chart"); + if (showMedicationsCard) revealChartIds.push("medications"); + const { revealed: chartsRevealed, markReady: markChartReady } = + useDashboardChartReveal(revealChartIds); + + // Glucose widget — visible iff layout enables it AND at least one reading exists. + // Glucose has no separate chart slot today, so the tile flag is the + // single source of truth for it. + const glucoseWidgetVisible = isTileVisible("glucose"); + const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); + const glucoseByContext = data?.glucoseByContext ?? {}; + const glucoseContexts = [ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", + ] as const; + const glucoseSummariesPresent = glucoseContexts.filter( + (ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0, + ); + const showGlucoseCards = + glucoseWidgetVisible && glucoseSummariesPresent.length > 0; + const glucoseLabelKey: Record = { + FASTING: "targets.glucoseFasting", + POSTPRANDIAL: "targets.glucosePostprandial", + RANDOM: "targets.glucoseRandom", + BEDTIME: "targets.glucoseBedtime", + }; + // v1.18.6 — band / target math is computed SERVER-side in the snapshot + // DTO (`targetBands`) so the client stops recomputing it from the + // profile (audit finding #3). When snapshot mode is on, read the + // resolved numbers straight from `snapshotQuery.data.targetBands`; the + // `!snapshotEnabled` branch keeps the legacy client-compute path so the + // non-snapshot fallback still works. The two produce byte-identical + // numbers (the server calls the SAME helpers — see + // `buildTargetBands` + its parity test). + const serverBands = snapshotEnabled + ? snapshotQuery.data?.targetBands + : undefined; + // Resolve the bands through the SAME `buildDashboardBands` helper the + // server snapshot uses, so the snapshot path and the client fallback + // are byte-identical by construction (no second inline copy of the + // band math to drift). Always compute the client copy from the profile + // — it is pure + cheap — so a still-loading snapshot frame keeps real + // bands rather than blank charts; `serverBands ?? clientBands` then + // prefers the authoritative server numbers the moment they arrive. + // Inputs only change with profile facts, so memoise the band math — it + // ran on every dashboard render (and got discarded by `serverBands ??` + // in the snapshot steady state) before this. Keyed on the `user` object + // identity so the React Compiler can preserve the memo (a narrower + // property list trips `preserve-manual-memoization`). + const clientBands = useMemo( + () => + buildDashboardBands({ + dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, + gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, + heightCm: user?.heightCm ?? null, + }), + [user], + ); + const bands = serverBands ?? clientBands; + const bpTargets = bands.bpTargets; + const weightRange = bands.weightRange; + const weightBands = bands.weightBands ?? undefined; + const bpTargetZones = bpTargets + ? [ + { + min: bpTargets.sysLow, + max: bpTargets.sysHigh, + color: "var(--chart-3)", + opacity: 0.21, + label: t("charts.systolic"), + textColor: "var(--chart-3)", + lineOpacity: 0.24, + }, + { + min: bpTargets.diaLow, + max: bpTargets.diaHigh, + color: "var(--chart-4)", + opacity: 0.21, + label: t("charts.diastolic"), + textColor: "var(--chart-4)", + lineOpacity: 0.24, + }, + ] + : undefined; + const bpSysRange = bands.bpSysRange; + const bpDiaRange = bands.bpDiaRange; + const pulseDisplayRange = bands.pulseDisplayRange; + const pulseBands = bands.pulseBands; + const bodyFatBands = bands.bodyFatBands; + + // v1.18.6 — ONE batched fetch for every visible non-sleep chart series + // (audit finding #2). Pre-fix, each `` fired its own + // `/api/measurements?...&source=rollup` round-trip, so a 6-8 chart + // dashboard issued 6-8 parallel requests on load. The batched endpoint + // returns all of them in one response; each chart reads its slice via + // `preloadedSeries` instead of self-fetching. Sleep stays on its + // dedicated per-night `/series` adapter (not rollup-backed), so it is + // never included here and the sleep chart keeps its own fetch. + // + // The window matches the charts' default 30-point view (the dashboard + // mounts them with no range-tab interaction); a chart whose window + // widens past this slice (range tab / comparison) drops batched + // coverage and self-fetches for the new window — see `usePreloaded`. + const batchTypeSet = new Set(); + if (showWeightChart) batchTypeSet.add("WEIGHT"); + if (showBpCharts) { + batchTypeSet.add("BLOOD_PRESSURE_SYS"); + batchTypeSet.add("BLOOD_PRESSURE_DIA"); + } + if (showPulseChart) + batchTypeSet.add(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE"); + if (showBodyFatChart) batchTypeSet.add("BODY_FAT"); + if (showStepsChart) batchTypeSet.add("ACTIVITY_STEPS"); + const batchChartTypes = Array.from(batchTypeSet); + + // Stable day-bucketed window so the cache key is stable across the day + // (mirrors the chart's own ISO-window stability contract). Computed + // once per mount via lazy state so a re-render never re-keys the batch. + const [batchWindow] = useState(() => { + const to = new Date(); + to.setHours(23, 59, 59, 999); + const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000); + return { from: from.toISOString(), to: to.toISOString() }; + }); + + const { data: batchedSeries } = useQuery({ + queryKey: queryKeys.chartSeriesBatch( + batchChartTypes.join(","), + batchWindow.from, + batchWindow.to, + ), + enabled: isAuthenticated && mounted && batchChartTypes.length > 0, + staleTime: 60_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + queryFn: async () => { + const sp = new URLSearchParams(); + sp.set("types", batchChartTypes.join(",")); + sp.set("from", batchWindow.from); + sp.set("to", batchWindow.to); + const res = await apiGet<{ + series: Record< + string, + Array<{ + value: number; + measuredAt: string; + count?: number; + minValue?: number | null; + maxValue?: number | null; + }> + >; + }>(`/api/measurements/series-batch?${sp}`); + return res.series; + }, + }); + + // Per-chart slices in the chart's `MeasurementApiRow` shape. The batched + // rows already carry `measuredAt` / `value` / `count` / min / max — the + // exact fields the chart's bucketing loop reads. + const preloadedSeries = batchedSeries; + // v1.7.0 — the primary data source differs by flag. In snapshot + // mode `analyticsSlimQuery` is `enabled: false`, and a disabled + // TanStack query reports `fetchStatus: "idle"` → `isLoading` is + // always `false`. Gating the skeleton on the slim query then + // never fires under snapshot mode, so the empty-state branch + // below flashes for the whole snapshot fetch. Key the loading + // flag off whichever query is actually driving the tiles. + // v1.16.4 — `!mounted` pins the SSR pass AND the hydration + // render to the skeleton branch. Server-side the data queries + // are `enabled: false` (no auth) → `isLoading: false`, which + // used to select the empty state; a late-hydrating boundary + // then saw `isAuthenticated` already resolved and rendered the + // skeleton instead — React #418. See `useMounted`. + // v1.16.9 — hoisted out of the strip IIFE: the hero band below + // shares the same gate so its skeleton and the tile silhouettes + // swap to content in the same render pass. + const primaryLoading = + !mounted || + (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); + + // v1.16.9 — the greeting paragraph moved off the header into the + // hero band (it derives from the snapshot's server-computed + // `greetingHour`, so the old client-side Intl hour walk retired + // with it). The hero gates on the layout's `heroVisible` flag + // (opt-in; only the literal `true` renders) and holds + // a footprint-identical skeleton while the snapshot is in flight, + // so the band swaps in place — no growth, no greeting flash. When + // the hero does NOT render (toggle off, or snapshot flag off) the + // header takes the greeting back — it must never disappear with the + // band. + const heroVisible = snapshotEnabled && layout.heroVisible === true; + const heroSnapshot = snapshotQuery.data; + + return ( +
+ + + {heroVisible && + (primaryLoading || !heroSnapshot ? ( + + ) : ( + + ))} + + {/* v1.18.6 — the spotlight tour launcher moved to the app-shell + * (`AuthShell`) so its overlay survives the cross-page + * `router.push`es the module tour makes. The dashboard no longer + * mounts it. */} + + {/* Quick Entry Sheets — bottom-sheet on ` setQuickEntryDialog(null)} + /> + + {(() => { + type TrendEntry = { id: string; order: number; node: React.ReactNode }; + const trendCards: TrendEntry[] = []; + + if (showWeightTile) { + trendCards.push({ + id: "weight", + order: widgetOrder("weight"), + node: ( + + ), + }); + } + if (showBpTiles) { + // BP is conceptually one signal but visually two tiles (sys + dia) + // so the user sees both numbers side by side at the same size as + // every other tile in the strip. v1.4.3 first attempted a + // combined tile with `secondary` values — the maintainer preferred two + // distinct tiles with consistent symmetric widths. + trendCards.push({ + id: "bp-sys", + order: widgetOrder("bp"), + node: ( + + ), + }); + trendCards.push({ + id: "bp-dia", + // Sub-order keeps dia immediately after sys; the +0.001 leaves + // headroom for any future BP-related tile slotted between them. + order: widgetOrder("bp") + 0.001, + node: ( + + ), + }); + } + if (showPulseTile) { + trendCards.push({ + id: "pulse", + order: widgetOrder("pulse"), + node: ( + + ), + }); + } + if (showBodyFatTile) { + trendCards.push({ + id: "bodyFat", + order: widgetOrder("bodyFat"), + node: ( + + ), + }); + } + if (showMoodTile) { + trendCards.push({ + id: "mood", + order: widgetOrder("mood"), + node: ( + + ), + }); + } + if (showSleepTile) { + trendCards.push({ + id: "sleep", + order: widgetOrder("sleep"), + node: ( + + ), + }); + } + if (showStepsTile) { + trendCards.push({ + id: "steps", + order: widgetOrder("steps"), + node: ( + + ), + }); + } + // v1.4.25 W8d — VO2 max trend tile. Self-gates on the + // `vo2Max` widget being enabled (Settings → Dashboard) AND + // the analytics summary carrying at least one sample. Higher + // VO2 max is better, so the directionSentiment is up-good and + // an upward 30-day slope renders the green arrow. Unit + // matches the canonical DB unit in + // src/lib/validations/measurement.ts. + if (showVo2Tile) { + trendCards.push({ + id: "vo2Max", + order: widgetOrder("vo2Max"), + node: ( + + ), + }); + } + if (showBpInTargetTile) { + /* v1.4.22 A2 — feature parity with every other tile. + Synthesise a slope from the difference between the 7-day + and 30-day in-target shares: when the recent week is + above the recent month, the metric is improving (up-good + ⇒ green arrow); when below, it's slipping. The + trend7Delta is the same number as the arrow's underlying + signal, surfaced as "(+5)" next to `7d:` so the tile + matches the (weight / BP / pulse) call-site contract. + Comparison overlay routes through the same global + `compareBaseline` / `tileCompareDelta` pipeline as every + other tile; we only have a single % series for the BP + tile (no DataSummary) so the prior-period delta uses + `bpInTargetPctAllTime` as the long-arc baseline — when + comparison is off the field stays null. */ + // v1.4.28 FB-C1 + FB-C2 — rewrite the BD-Zielbereich tile + // against the shared `` primitive so it matches + // the Weight / BP / Pulse sibling tiles exactly. The + // synthetic `bpSlope30 = bpTrendDelta / 30` block produced a + // small fractional float that the TrendCard's date-shaped + // formatter pipeline rendered as "1.1." — the regression the + // maintainer flagged in the post-v1.4.27 walk-through. The + // all-time aggregate moves to the Insights blood-pressure + // target panel which already shows the same number with more + // context; the dashboard tile no longer needs to carry it. + // `avgAllTime` + // also retires from the TrendCard API (this was its only + // consumer). + const bp7 = data?.bpInTargetPct7d ?? null; + const bp30 = data?.bpInTargetPct30d ?? null; + const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null; + const bpPriorYear = data?.bpInTargetPctPriorYear ?? null; + const bpTrendDelta = + bp7 !== null && bp30 !== null ? bp7 - bp30 : null; + const bpComparePrior = + compareBaseline === "lastMonth" + ? bpPriorMonth + : compareBaseline === "lastYear" + ? bpPriorYear + : null; + const bpCompareDelta = + compareBaseline === "none" || + bp30 === null || + bpComparePrior === null + ? null + : Math.round((bp30 - bpComparePrior) * 10) / 10; + // v1.17 W1b — thin-data gate + dynamic span label. Below the + // confidence floor the tile narrates "collecting data" instead of + // a hard percentage, and the label names the EFFECTIVE span + // ("· 23 T" until ~90 days of history exist) rather than a + // dishonest static "· 90 T". When the count is unknown (snapshot + // still on the slim phase) the tile keeps the legacy 90-day label + // and shows whatever percentage arrived. + const bpCount90 = data?.bpInTargetCount90 ?? null; + const bpSpanDays90 = data?.bpInTargetSpanDays90 ?? null; + const bpSufficient = + bpCount90 === null || isWindowSufficient(bpCount90); + const bpLabel = + bpSpanDays90 != null + ? t("dashboard.bpInTargetWindowDynamic", { days: bpSpanDays90 }) + : t("dashboard.bpInTargetWindow90"); + trendCards.push({ + id: "bpInTarget", + order: widgetOrder("bpInTarget"), + node: ( + + ), + }); + } + if (showGlucoseCards) { + const glucoseOrder = widgetOrder("glucose"); + glucoseSummariesPresent.forEach((ctx, idx) => { + const s = glucoseByContext[ctx]; + trendCards.push({ + id: `glucose-${ctx}`, + // sub-order so all glucose cards stay in a block and order stable + order: glucoseOrder + idx / 1000, + node: ( + + ), + }); + }); + } + + trendCards.sort((a, b) => a.order - b.order); + + type ChartEntry = { + id: string; + order: number; + node: React.ReactNode; + /** + * Total raw readings for this metric. <5 surfaces a contextual + * "First trend after 5 readings" hint underneath the chart; + * undefined disables the hint (e.g. medications card). + */ + count?: number; + /** + * v1.16.0 — entry participates in the shared chart reveal: + * the cell holds its skeleton until every gated chart's data + * settled (or the 2 s fallback fires). Data-backed charts set + * this; the self-skeletoning achievements / recent-workouts + * cards do not. + */ + revealGated?: boolean; + }; + const charts: ChartEntry[] = []; + if (showWeightChart) { + charts.push({ + id: "weight-chart", + order: widgetOrder("weight"), + count: w?.count ?? 0, + revealGated: true, + node: ( + markChartReady("weight-chart")} + preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="weight" + types={["WEIGHT"]} + title={t("dashboard.weight")} + colors={["var(--chart-1)"]} + unit="kg" + valueBands={weightBands} + compareBaseline={compareBaseline} + userTimezone={user?.timezone} + /> + ), + }); + if (user?.heightCm) { + charts.push({ + id: "bmi-chart", + order: widgetOrder("weight") + 0.5, + revealGated: true, + node: ( + markChartReady("bmi-chart")} + preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="bmi" + types={["WEIGHT"]} + title={t("targets.bmi")} + colors={["var(--dracula-yellow)"]} + unit="kg/m²" + valueMode="bmi" + valueBands={[ + { + min: 0, + max: 17, + color: "var(--destructive)", + opacity: 0.16, + }, + { + min: 17, + max: 18.5, + color: "var(--warning)", + opacity: 0.18, + }, + { + min: 18.5, + max: 24.9, + color: "var(--success)", + opacity: 0.2, + }, + { + min: 24.9, + max: 29.9, + color: "var(--warning)", + opacity: 0.18, + }, + { + min: 29.9, + max: 120, + color: "var(--destructive)", + opacity: 0.16, + }, + ]} + compareBaseline={compareBaseline} + /> + ), + }); + } + } + if (showBpCharts) { + charts.push({ + id: "bp-chart", + order: widgetOrder("bp"), + count: Math.max(sys?.count ?? 0, dia?.count ?? 0), + revealGated: true, + node: ( + markChartReady("bp-chart")} + preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="bp" + types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} + title={t("dashboard.bloodPressure")} + colors={["var(--chart-3)", "var(--chart-4)"]} + unit="mmHg" + yAxisUnit="Hg" + targetZones={bpTargetZones} + compareBaseline={compareBaseline} + userTimezone={user?.timezone} + /> + ), + }); + } + if (showPulseChart) { + charts.push({ + id: "pulse-chart", + order: widgetOrder("pulse"), + count: p?.count ?? 0, + revealGated: true, + node: ( + markChartReady("pulse-chart")} + preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="pulse" + // v1.15.12 A2 — chart the RESTING series against the + // resting band when available; otherwise chart raw heart + // rate WITHOUT the resting-band overlay (it would mark + // expected-high workout HR as "outside target"). + types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]} + title={t("dashboard.pulse")} + colors={["var(--success)"]} + unit="bpm" + valueBands={hasRestingHr ? pulseBands : undefined} + compareBaseline={compareBaseline} + userTimezone={user?.timezone} + /> + ), + }); + } + if (showBodyFatChart) { + charts.push({ + id: "bodyFat-chart", + order: widgetOrder("bodyFat"), + count: bf?.count ?? 0, + revealGated: true, + node: ( + markChartReady("bodyFat-chart")} + preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="bodyFat" + types={["BODY_FAT"]} + title={t("dashboard.bodyFat")} + colors={["var(--warning)"]} + unit="%" + valueBands={bodyFatBands} + compareBaseline={compareBaseline} + userTimezone={user?.timezone} + /> + ), + }); + } + if (showMoodChart) { + charts.push({ + id: "mood-chart", + order: widgetOrder("mood"), + count: moodSummary?.count ?? 0, + revealGated: true, + node: ( + markChartReady("mood-chart")} + compareBaseline={compareBaseline} + chartKey="mood" + userTimezone={user?.timezone} + /> + ), + }); + } + if (showSleepChart) { + charts.push({ + id: "sleep-chart", + order: widgetOrder("sleep"), + count: sleepSummary?.count ?? 0, + revealGated: true, + node: ( + markChartReady("sleep-chart")} + chartKey="sleep" + types={["SLEEP_DURATION"]} + title={t("dashboard.sleep") ?? "Sleep"} + colors={["var(--chart-4)"]} + unit="h" + compareBaseline={compareBaseline} + userTimezone={user?.timezone} + /> + ), + }); + } + if (showStepsChart) { + charts.push({ + id: "steps-chart", + order: widgetOrder("steps"), + count: stepsSummary?.count ?? 0, + revealGated: true, + node: ( + markChartReady("steps-chart")} + preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} + chartKey="steps" + types={["ACTIVITY_STEPS"]} + title={t("dashboard.steps") ?? "Steps"} + colors={["var(--success)"]} + compareBaseline={compareBaseline} + userTimezone={user?.timezone} + /> + ), + }); + } + if (showMedicationsCard) { + // v1.4.15 Fix 2: the toggle existed since v1.1 but the dashboard + // slot only rendered a static placeholder (icon + title), so + // flipping the layout switch on did nothing visible. Wire the + // real chart that consumes + // `/api/medications/intake?scope=compliance&days=N`. + charts.push({ + id: "medications", + order: widgetOrder("medications"), + revealGated: true, + node: ( + markChartReady("medications")} + compareBaseline={compareBaseline} + userTimezone={user?.timezone} + /> + ), + }); + } + if (showVorsorgeCard) { + // v1.18.2 — Vorsorge preventive-care summary card. Slotted via + // the layout `order` like medications; self-fetches its reminder + // list and self-skeletons, so it stays out of the reveal gate + // (no chart-shaped data-ready signal). + charts.push({ + id: "vorsorge", + order: widgetOrder("vorsorge"), + node: , + }); + } + if (showAchievementsCard) { + // v1.4.15 phase-B4 — slotted at the user's chosen position via + // the layout `order`. Default order from + // `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row) + // which matches the maintainer's brief "below the chart row". + charts.push({ + id: "achievements", + order: widgetOrder("achievements"), + node: , + }); + } + if (showRecentWorkoutsTile) { + // v1.4.32 — slotted via the same `order` mechanism. Self-gates + // on the workouts query response inside the tile itself. + charts.push({ + id: "recentWorkouts", + order: widgetOrder("recentWorkouts"), + node: , + }); + } + + charts.sort((a, b) => a.order - b.order); + + // v1.4.43 W11-M5 — tile-strip skeleton during slow slim-analytics + // fetches. + // + // `trendCards` only fills once the slim `/api/analytics` slice + // resolves and reports per-type `count > 0` flags. The v1.4.39.2 + // slim/thick split keeps the strip painting fast when slim wins, + // but when *both* slices lag (cache eviction, cold start) the + // user used to see the page header + 0 tiles + then 7 tiles + // appear at once 9 s later. The audit recommendation is to + // render a layout-stable tile silhouette keyed off the user's + // configured tile count so the strip's footprint is reserved + // during the slow window. The skeleton swaps in for the real + // strip the moment `analyticsSlimQuery.isLoading` flips false. + // v1.7.0 — count only WEB-known tiles. The stored layout now + // round-trips the 11 iOS-only ids (so the native client can drop + // its merge workarounds), but the web dashboard has no tile + // component for them; including them here would over-reserve the + // skeleton silhouette by rows that never paint. + // v1.16.8 — narrowed further to the tile-CAPABLE set (see + // `resolveConfiguredTileCount`): the old count included + // `medications` + `recentWorkouts` (chart-row cards with no + // strip tile) and undercounted `bp` (one widget id, two tiles), + // so the silhouettes reshuffled when the data landed. + const configuredTileCount = resolveConfiguredTileCount(layout); + // `primaryLoading` is derived once in the component body (above + // the return) — the hero band and this strip share the same + // gate so both surfaces swap in the same render pass. + // v1.16.8 — chart-row reservation while the snapshot is in + // flight. The chart visibility gates above need snapshot data + // (`count > 0`), so the cold chart row only carried the + // layout-gated medications card and then grew by ~1000 px when + // the snapshot landed. Reserve layout-stable ChartSkeleton + // cells for the charts the layout expects (minus the ones + // already mounted) so the page holds roughly its final + // footprint from first paint and the snapshot landing swaps + // content instead of growing the page. + // `mounted &&` pins the hydration render to the SSR output: the + // auth query is fired by the early-hydrating shell, so `user` + // can already carry a heightCm when this late-hydrating page + // boundary replays its first render — the extra BMI silhouette + // then mismatched the server HTML (React #418). The count may + // only personalise from the first client re-render. + const chartRowPlaceholderCount = primaryLoading + ? Math.max( + 0, + resolveChartRowPlaceholderCount(layout, { + hasHeightCm: mounted && Boolean(user?.heightCm), + }) - charts.length, + ) + : 0; + const { showTileStripSkeleton, showEmptyState } = + resolveDashboardFirstPaintGate({ + trendCardCount: trendCards.length, + chartCount: charts.length, + configuredTileCount, + primaryLoading, + }); + + // v1.4.15 phase-C5: dashboard fully-empty state. When no tile + // and no chart has data the dashboard would otherwise paint a + // 0-px tile strip with the welcome banner above it — visually + // looked like a half-broken page. Render an EmptyState that + // re-uses the existing quick-entry dialog so the user has a + // single click into "Log measurement" without leaving the page. + // The GettingStartedChecklist above renders its own self-gated + // surface for very-new accounts; this empty state covers the + // case where the checklist has been dismissed but no data was + // logged afterwards. + // + // v1.4.43 W11 — the empty-state only fires once the slim slice + // resolves with no tiles to show. While slim is in flight the + // skeleton strip below carries the layout footprint. + if (showEmptyState) { + return ( + } + title={t("dashboard.emptyTitle")} + description={t("dashboard.emptyDescription")} + action={ + + } + /> + ); + } + + return ( + <> + {/* v1.4: dashboard tiles are *always* a single row. + * Maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row + * tile strip breaks the visual hierarchy and reads like an + * Excel grid. Total width caps at the parent container — + * exactly the chart-width below. When the active tile count + * exceeds what fits the viewport, the strip horizontal-scrolls + * instead of wrapping; the user trims the set in + * Settings → Dashboard (`/settings/dashboard`). + * Each tile keeps a `min-w-[10rem]` so a single tile still + * looks substantial on a wide screen, and `snap-x snap-mandatory` + * makes the scroll feel deliberate rather than arbitrary on + * touch. + */} + {/* v1.4.16 Fix A5: hide the strip entirely when the user + turned off every tile. Until v1.4.15 the wrapper rendered + an empty grid even with zero tiles — visually a thin gap + the maintainer described as "awkward". Charts below still render so + the page is not empty; the tile-strip just goes away. + The constraint the maintainer named — "immer die gesamte Spalte + breit und immer der gleichen Höhe" — is preserved by the + CSS-grid `auto-fit + minmax + auto-rows-fr` track that + continues to give every visible tile equal width / equal + height for any non-zero count. */} + {showTileStripSkeleton && ( + + )} + {trendCards.length > 0 && ( +
+ {trendCards.map((entry) => ( +
+ {/* + * Per-tile `` boundary with a layout-stable + * placeholder that mirrors the trend-card chrome. Tile + * bodies are synchronous today so the fallback rarely + * paints, but a future RSC hoist of any tile slot + * would otherwise leave the grid track empty and + * trigger CLS as the cell paints in. + * + * v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder. + */} + {/* v1.16.0 — the fallback is the same structured + silhouette the tile-strip skeleton paints, so a + suspending tile slot previews the final shape + instead of an empty card. */} + }> + {entry.node} + +
+ ))} +
+ )} + {charts.map((entry) => ( +
+ {/* + * v1.4.40 W-RSC — per-tile `` boundary so each + * chart paints independently rather than the row blocking + * on the slowest fetch (audit-H2 + brief C1). The + * `next/dynamic({ ssr: false })` lazy-load contract on + * `HealthChartDynamic` / `MoodChart` / + * `MedicationComplianceChart` already paints a + * `` while the JS chunk is in flight; this + * Suspense layer lifts the same skeleton to a streaming- + * compatible boundary so a future migration to + * `useSuspenseQuery` (when we replace the per-chart + * `["chart-data", …]` fetches with the slim-analytics- + * derived store) automatically buckets each chart's + * loading state to its own cell, with no further + * call-site changes. + * + * Today, the boundary is a structural no-op for the + * dynamic-loaded charts because their loading skeleton + * lives inside the dynamic factory. The benefit is + * future-proofing the composition: any descendant that + * later suspends (e.g. an RSC migration of a static + * legend, or a server-streamed sparkline) gets its own + * fallback without re-architecting the row. + */} + {entry.revealGated ? ( + /* v1.16.0 — gated cell: the chart mounts (its query + fires) but stays visually on the layout-stable + skeleton until the shared reveal flips — all gated + charts swap in the same frame with one short + fade-in (motion-safe). The TrendHint rides inside + the cell so it appears with its chart. */ + + }> + {entry.node} + + {entry.count != null ? ( + + ) : null} + + ) : ( + <> + }> + {entry.node} + + {entry.count != null ? ( + + ) : null} + + )} +
+ ))} + {/* v1.16.8 — layout-stable chart-row reservation. While the + snapshot is in flight the data-gated chart entries above + cannot exist yet, so these silhouettes carry the row's + footprint; the moment the snapshot lands the real cells + render in their place (same render pass — no growth, a + swap). `aria-hidden` like the tile-strip skeleton: the + mounted charts announce their own loading state. */} + {chartRowPlaceholderCount > 0 && ( + + )} + + ); + })()} + + {/* v1.4: Getting-started checklist for brand-new users. Self-gates + * visibility on (onboardingCompletedAt == null || measurementCount + * < 5) and disappears once dismissed or fully complete. + * v1.16.8 — moved BELOW the tile strip + chart row: the card only + * mounts once the snapshot resolves, and sitting above the strip + * it pushed every tile and chart down when it popped in. At the + * bottom its late arrival shifts nothing — the fold-priority + * content (tiles, charts) keeps a stable position, and the + * near-empty dashboards of the new accounts the card targets keep + * it visible without scrolling anyway. */} + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 04da3b69d..291a8057d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,1727 +1,78 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import React, { Suspense, useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { useMounted } from "@/hooks/use-mounted"; -import { - Activity, - Droplet, - Footprints, - Gauge, - Heart, - Moon, - Percent, - Plus, - Smile, - Target, - TrendingUp, -} from "lucide-react"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; -import { cn } from "@/lib/utils"; -import { - resolveDashboardLayout, - type DashboardLayout, -} from "@/lib/dashboard-layout"; -import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; -import dynamic from "next/dynamic"; -import { Button } from "@/components/ui/button"; -import { EmptyState } from "@/components/ui/empty-state"; -import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; -import { ChartSkeleton } from "@/components/charts/chart-skeleton"; -import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { importWithRetry } from "@/lib/retry-import"; -import { - DashboardChartCell, - useDashboardChartReveal, -} from "@/components/dashboard/chart-reveal"; -import { DashboardHeader } from "@/components/dashboard/dashboard-header"; -import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero"; -import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton"; -import { - QuickEntrySheets, - type QuickEntryDialog, -} from "@/components/dashboard/quick-entry-sheets"; -import { - getRangeColorClass, - getRangeHint, - toHoursSummary, -} from "@/components/dashboard/range-display"; -import { TrendCard } from "@/components/charts/trend-card"; -import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; -import { TrendHint } from "@/components/charts/trend-hint"; -import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; -import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; -import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; -import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; -import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; - -// v1.4.40 W-RSC — module-scope so the option object is stable across -// renders (audit-M2). Pre-fix the same literal was declared inside the -// component body, so every render created a fresh `{}` reference; -// TanStack does a shallow compare and shrugs at the equivalent values, -// but the slot is one future-callback-field away from cache poisoning. -const DASHBOARD_QUERY_OPTS = { - staleTime: 60_000, - refetchOnWindowFocus: false, -} as const; - -// v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches. -// Threaded to every chart as `preloadedCoverageDays` so a chart reads the -// batched slice ONLY for a range tab whose window fits within it (7 / 30); -// the 90 / All tabs exceed it and self-fetch the wider window. Pre-fix the -// chart trusted the batch for any window > 7 days, so 90 / All silently -// re-rendered this ~30-day slice. Keep this in lockstep with `batchWindow`. -const BATCH_COVERAGE_DAYS = 31; - -// v1.16.8 — the loaders retry a rejected chunk import once (a lazy -// import caches its rejection permanently, so a transient 404 from a -// stale shell used to brick the card for the session) and each mount -// wraps in `` so a chunk that still fails degrades -// to ONE error card instead of bubbling to the route-level `error.tsx`. -const MoodChartLazy = dynamic( - () => - importWithRetry(() => import("@/components/charts/chart-runtime")).then( - (mod) => ({ default: mod.MoodChart }), - ), - { ssr: false, loading: () => }, -); -const MoodChart = (props: React.ComponentProps) => ( - - - -); -const MedicationComplianceChartLazy = dynamic( - () => - importWithRetry( - () => import("@/components/charts/chart-runtime"), - ).then((mod) => ({ default: mod.MedicationComplianceChart })), - { ssr: false, loading: () => }, -); -const MedicationComplianceChart = ( - props: React.ComponentProps, -) => ( - - - -); -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { getSession } from "@/lib/auth/session"; +import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { queryKeys } from "@/lib/query-keys"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; -import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; -import { isWindowSufficient } from "@/lib/analytics/window-confidence"; -import { buildDashboardBands } from "@/lib/dashboard/bands"; -import { apiGet } from "@/lib/api/api-fetch"; -// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to -// `@/components/dashboard/dashboard-gates`. Re-exported here so the -// existing `../page` unit-test imports stay valid while `page.tsx` thins -// to an orchestrator. -export { - resolveDashboardFirstPaintGate, - resolveConfiguredTileCount, - resolveChartRowPlaceholderCount, -} from "@/components/dashboard/dashboard-gates"; -import { - resolveDashboardFirstPaintGate, - resolveConfiguredTileCount, - resolveChartRowPlaceholderCount, -} from "@/components/dashboard/dashboard-gates"; +import DashboardPageClient from "./page-client"; -export default function DashboardPage() { - const { isAuthenticated, user } = useAuth(); - const mounted = useMounted(); - const { t } = useTranslations(); - const fmt = useFormatters(); - const [quickEntryDialog, setQuickEntryDialog] = - useState(null); - // v1.4.29 M5 — the three inline dashboard queries default to a - // 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch - // storm. None of these are real-time data; minute-scale staleness - // is fine and the dashboard's chart queries already match this - // cadence. - // - // v1.4.40 W-RSC — `DASHBOARD_QUERY_OPTS` hoisted to module scope - // (audit-M2 — stable reference across renders). - - // v1.4.39.2 — split the dashboard's analytics consumption so the - // tile-strip paints from the slim slice and the BD-Zielbereich + - // glucose tiles stream in from the thick slice afterwards. - // - // Pre-fix: a single `useAnalyticsQuery()` against `/api/analytics` - // (thick slice) blocked every per-type tile until the heavy fan-out - // resolved. Mood and medication tiles paint from their own dedicated - // endpoints and arrived first; the per-type measurement tiles then - // arrived as one burst once the full slice landed — the maintainer reported - // this "blocked-then-burst" pattern as "etwas nervig". - // - // Post-fix: two parallel queries. The slim slice (`?slice=summaries`) - // resolves the per-type DataSummary headlines + `lastSeenByType` and - // typically returns in well under a second once the rollup tier is - // warm; that paints every per-type tile in the strip. The thick - // slice resolves `bpInTargetPct*` + `glucoseByContext` for the - // BD-Zielbereich and glucose tiles, which stream in independently. - // Both queries share `caches.analytics` server-side so warm hits - // stay free, and TanStack's parallel-mounting keeps the network fan- - // out flat. - // Unified first-paint snapshot (reversible rollout flag, default ON). - // Every tile hydrates from ONE un-gated `/api/dashboard/snapshot` cell - // so the whole strip shares one completion moment and the - // `/api/auth/me` round-trip leaves the cold critical path. Set - // `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy - // four independent cells. - const snapshotEnabled = isDashboardSnapshotEnabled(); - const snapshotQuery = useDashboardSnapshot(snapshotEnabled); - - const analyticsSlimQuery = useAnalyticsQuery({ - slice: "summaries", - enabled: !snapshotEnabled && isAuthenticated, - }); - const analyticsThickQuery = useAnalyticsQuery({ - enabled: !snapshotEnabled && isAuthenticated, - }); - const data = useMemo(() => { - // v1.7.0 W6 — snapshot path: assemble the same `AnalyticsData` - // shape from the single snapshot cell so every downstream tile - // reads unchanged. `extras` is null on a rollup-coverage miss - // (two-phase contract) → the BD-Zielbereich + glucose fields stay - // undefined and those tiles render their per-tile shimmer while the - // rest of the strip paints. - if (snapshotEnabled) { - const snap = snapshotQuery.data; - if (!snap) return undefined; - return { - summaries: snap.tiles.summaries, - lastSeenByType: snap.tiles.lastSeenByType, - bpInTargetPct: snap.extras?.bpInTargetPct ?? null, - bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null, - bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null, - bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null, - bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null, - bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null, - bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null, - bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null, - glucoseByContext: snap.extras?.glucoseByContext as - Record | undefined, - }; +/** + * Thin RSC wrapper around the (client) dashboard page. + * + * The measured first-load gap on `/` was never the server: FCP painted the + * SSR skeleton shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) + * because the real tile content waited for the full JS download + hydrate + + * a CLIENT-side snapshot fetch — while `/api/dashboard/snapshot` itself + * answers in ~8 ms server-side. This wrapper runs that same cached read + * during SSR and hands it to TanStack through `HydrationBoundary`, so the + * first HTML already carries real tile data and the mounted snapshot cell + * (`staleTime` 60 s, `refetchOnMount: false`) starts warm instead of + * skeleton-first. + * + * Contract notes: + * - Query keys come ONLY from the central factory. The live cell is + * locale-keyed (`dashboardSnapshot(locale)`), and the locale here is the + * same resolution the API route uses (`resolveServerLocale`), which the + * client provider mirrors via the layout's `resolveInitialLocale`. + * - The payload is JSON-round-tripped so the hydrated shape is exactly + * what the client `queryFn` would produce from the wire ((await + * res.json()).data — Dates as ISO strings), never a Date-carrying + * sibling that would poison the cell for later readers. + * - `dashboardWidgets` is seeded alongside, mirroring the client cell's + * own warm-up in `use-dashboard-snapshot.ts` (the overlay-prefs hook + * reads it before the chart cells fire). + * - Fail-soft: no session (proxy is mid-redirect), a builder hiccup, or a + * DB blip renders the page exactly as before this wrapper existed — the + * client cell owns the fetch then. + */ +export default async function DashboardPage() { + // Operator/test escape hatch: `DASHBOARD_SSR_PREFETCH=false` renders the + // pure client-fetch dashboard (pre-prefetch behaviour). The e2e server + // sets it so Playwright route mocks — which only see CLIENT fetches — + // keep governing what the dashboard paints. + if (process.env.DASHBOARD_SSR_PREFETCH === "false") { + return ; + } + let dehydratedState = null; + try { + const session = await getSession(); + if (session) { + const { body, locale } = await readDashboardSnapshotCached(session.user); + // Match the client cell's wire shape exactly (JSON semantics, ISO + // date strings) — same-key-different-shape is silent cache poison. + const wireBody = JSON.parse(JSON.stringify(body)) as unknown as Record< + string, + unknown + >; + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody); + queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout); + dehydratedState = dehydrate(queryClient); } - // v1.4.39.3 — the merge moved to `mergeSlimAndThickAnalytics` so - // the empty-slim-vs-populated-thick edge has direct unit - // coverage. Pre-fix the inline `slim?.summaries ?? thick?.summaries` - // short-circuited on a truthy-but-empty `{}` from the slim slice - // and blanked the tile strip even when thick carried the full - // payload — the regression the maintainer's v1.4.39.3 e2e CI flagged across - // eight dashboard / chart specs. The helper falls back to thick - // when slim resolves with no content and otherwise keeps the - // v1.4.39.2 slim-wins-first progressive-paint contract. - const merged = mergeSlimAndThickAnalytics( - analyticsSlimQuery.data, - analyticsThickQuery.data, - ); - if (!merged) return undefined; - return { - summaries: merged.summaries, - lastSeenByType: merged.lastSeenByType, - bpInTargetPct: merged.bpInTargetPct, - bpInTargetPct7d: merged.bpInTargetPct7d, - bpInTargetPct30d: merged.bpInTargetPct30d, - bpInTargetPctAllTime: merged.bpInTargetPctAllTime, - bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth, - bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear, - bpInTargetCount90: merged.bpInTargetCount90, - bpInTargetSpanDays90: merged.bpInTargetSpanDays90, - glucoseByContext: merged.glucoseByContext as - Record | undefined, - }; - }, [ - snapshotEnabled, - snapshotQuery.data, - analyticsSlimQuery.data, - analyticsThickQuery.data, - ]); - - const { data: layoutDataLegacy } = useQuery({ - queryKey: queryKeys.dashboardWidgets(), - queryFn: async () => { - return apiGet("/api/dashboard/widgets"); - }, - enabled: !snapshotEnabled && isAuthenticated, - ...DASHBOARD_QUERY_OPTS, - }); - const layoutData = snapshotEnabled - ? snapshotQuery.data?.layout - : layoutDataLegacy; - - const { data: moodDataLegacy } = useQuery({ - queryKey: queryKeys.moodAnalytics(), - queryFn: async () => { - return apiGet<{ - entries: Array<{ date: string; score: number; samples: number }>; - summary: DataSummary; - }>("/api/mood/analytics"); - }, - enabled: !snapshotEnabled && isAuthenticated, - ...DASHBOARD_QUERY_OPTS, - }); - const moodData = snapshotEnabled - ? snapshotQuery.data - ? { - entries: snapshotQuery.data.tiles.mood.entries, - summary: (snapshotQuery.data.tiles.mood.summary ?? - undefined) as DataSummary, - } - : undefined - : moodDataLegacy; - - // v1.4.27 B1 — the dashboard's `` retired (it - // duplicated the much-richer `/insights` advisor surface). The advisor - // query lives on `/insights` directly; the dashboard no longer needs - // its own hook subscription, so the local `useInsightsAdvisorQuery` - // call dropped with the preview. - - // v1.4.27 — per-tile availability gates live a few lines below as the - // existing `hasWeight` / `hasBp` / `hasPulse` / `hasBodyFat` / `hasMood` / - // `hasSleep` / `hasSteps` flags. They mirror `hasMetricData` from - // `src/lib/insights/metric-availability.ts` for the routed Insights - // surfaces — both branches read `summaries[METRIC].count > 0`. - - const w = data?.summaries?.WEIGHT; - const sys = data?.summaries?.BLOOD_PRESSURE_SYS; - const dia = data?.summaries?.BLOOD_PRESSURE_DIA; - const p = data?.summaries?.PULSE; - // v1.15.12 A2 — the resting-pulse target band is judged against the - // RESTING_HEART_RATE series (Apple's clean daily resting figure), not - // raw PULSE which mixes in workout HR. When the user has resting rows, - // the pulse tile shows the resting figure + the resting-band colour; - // when only raw PULSE exists, the tile shows heart rate WITHOUT the - // resting-band colour overlay (no "outside target" over workout HR). - const rhr = data?.summaries?.RESTING_HEART_RATE; - const hasRestingHr = (rhr?.count ?? 0) > 0; - const pulseTileSummary = hasRestingHr ? rhr : p; - const bf = data?.summaries?.BODY_FAT; - const sleepSummary = data?.summaries?.SLEEP_DURATION; - // v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep - // totals in MINUTES (the server collapses the per-stage rows into one - // night value; see `summaries-slice.ts`). The sleep tile renders hours, - // so convert every value field minutes→hours here while keeping the - // staleness / count metadata untouched. This is the web-parity twin of - // the iOS dashboard-summary route which already emits `unit:"h"`. - const sleepSummaryHours = sleepSummary - ? toHoursSummary(sleepSummary) - : undefined; - const stepsSummary = data?.summaries?.ACTIVITY_STEPS; - // v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics - // auto-populates this summary because the route iterates over the - // full measurementTypeEnum.options list; no backend change needed. - const vo2Summary = data?.summaries?.VO2_MAX; - const moodSummary = moodData?.summary; - - // Resolve full dashboard layout — controls visibility + order of every widget - const layout = resolveDashboardLayout(layoutData); - /** - * v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) read - * from the resolved layout so every chart + tile on the dashboard - * receives the same value. "none" = comparison off (pre-B8 default). - */ - const compareBaseline = layout.comparisonBaseline ?? "none"; - - /** - * v1.4.16 phase B8 — derive the tile-delta value (current 30d avg - * minus prior-period 30d avg) for any DataSummary. Returns null - * when comparison is off OR either side is missing data so the - * tile can suppress the callout cleanly. - */ - const tileCompareDelta = ( - summary: DataSummary | null | undefined, - ): number | null => { - if (!summary || compareBaseline === "none") return null; - const current = summary.avg30 ?? null; - const prior = - compareBaseline === "lastMonth" - ? (summary.avg30LastMonth ?? null) - : (summary.avg30LastYear ?? null); - if (current === null || prior === null) return null; - return Math.round((current - prior) * 100) / 100; - }; - - /** - * v1.4.34 IW-B — read the per-type freshness map and surface the - * `daysAgo` value when the metric is older than a week. The - * tile-strip below forwards the result to `` - * which picks the bucket-aware copy (Xd / X weeks / X months) and - * paints the caption on the tile. Returns `null` for metrics with no - * reading yet OR within the fresh window so call sites stay - * undefined-safe and tiles with recent data paint byte-identical - * with the pre-v1.4.34 contract. - */ - const tileStaleDays = (type: string | null | undefined): number | null => { - if (!type) return null; - const entry = data?.lastSeenByType?.[type]; - if (!entry) return null; - return entry.daysAgo > 7 ? entry.daysAgo : null; - }; - /** Whether the widget's *chart* (lower row) shows. */ - const isChartVisible = (id: string) => - layout.widgets.find((widget) => widget.id === id)?.visible ?? false; - /** - * v1.4.15 Fix 5 — whether the widget's *tile* in the strip shows. - * Independent of the chart visibility so the user can hide chart but - * keep the tile (or vice versa) from Settings → Dashboard. Falls back - * to chart visibility for layouts saved before v1.4.15. - */ - const isTileVisible = (id: string) => { - const widget = layout.widgets.find((w) => w.id === id); - if (!widget) return false; - return typeof widget.tileVisible === "boolean" - ? widget.tileVisible - : widget.visible; - }; - const widgetOrder = (id: string) => - layout.widgets.find((widget) => widget.id === id)?.order ?? 999; - - // Data-floor gates (widget shows iff visible AND has data) - const hasWeight = (w?.count ?? 0) > 0; - const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0; - // v1.15.12 A2 — the pulse tile shows resting HR when available, else - // raw PULSE; either signal having data shows the tile. - const hasPulse = (p?.count ?? 0) > 0 || (rhr?.count ?? 0) > 0; - const hasBodyFat = (bf?.count ?? 0) > 0; - const hasMood = (moodSummary?.count ?? 0) > 0; - const hasSleep = (sleepSummary?.count ?? 0) > 0; - const hasSteps = (stepsSummary?.count ?? 0) > 0; - const hasVo2 = (vo2Summary?.count ?? 0) > 0; - /** - * v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %" - * placeholder doesn't ride on the dashboard when none of the user's - * paired readings sit inside the target band. The historical - * behaviour (`!= null`) painted the tile whenever the analytics - * route emitted a numeric percentage, including the legitimate-but- - * misleading zero. The corrected gate requires at least one window - * (7d, 30d, all-time) to report a non-zero share. When every - * window is zero, the tile is hidden — the user sees the BP charts - * + the Insights blood-pressure target panel for the deeper analysis - * instead. - * The audit's F4 reproduction sat on 540 BP samples with all sub- - * windows reading 0 % because the seed data straddled the target - * ceiling; the tile keeps its place once even one sub-window - * crosses zero. - */ - const hasBpInTarget = - data?.bpInTargetPct != null && - [ - data?.bpInTargetPct, - data?.bpInTargetPct7d, - data?.bpInTargetPct30d, - data?.bpInTargetPctAllTime, - ].some((pct) => pct != null && pct > 0); - - // Tile (strip) gates — controlled by the new `tileVisible` flag. - const showWeightTile = isTileVisible("weight") && hasWeight; - const showBpTiles = isTileVisible("bp") && hasBp; - const showPulseTile = isTileVisible("pulse") && hasPulse; - const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat; - const showMoodTile = isTileVisible("mood") && hasMood; - const showSleepTile = isTileVisible("sleep") && hasSleep; - // v1.18.0 B1 — steps are activity data tied to the workouts module - // surface; AND the resolved module flag (fail-open: only an explicit - // `false` hides it) so a disabled-workouts account drops the tile. - const showStepsTile = - isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false; - const showVo2Tile = isTileVisible("vo2Max") && hasVo2; - const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; - - // Chart (lower row) gates — controlled by the legacy `visible` flag. - const showWeightChart = isChartVisible("weight") && hasWeight; - const showBpCharts = isChartVisible("bp") && hasBp; - const showPulseChart = isChartVisible("pulse") && hasPulse; - const showBodyFatChart = isChartVisible("bodyFat") && hasBodyFat; - const showMoodChart = isChartVisible("mood") && hasMood; - const showSleepChart = isChartVisible("sleep") && hasSleep; - const showStepsChart = isChartVisible("steps") && hasSteps; - const showMedicationsCard = isChartVisible("medications"); - // v1.18.2 — Vorsorge summary card on the chart row (opt-in). Always-on - // data surface, so it gates on the layout toggle alone. - const showVorsorgeCard = isChartVisible("vorsorge"); - // `layoutData` is undefined until the real layout (snapshot or legacy - // widgets) resolves; `resolveDashboardLayout(undefined)` falls back to - // DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default. - // Gating layout-toggle-only cards on that fallback flashed them in for - // the load window and then retracted them for any user who had turned - // the widget OFF (their real layout arriving after first paint). The - // data-driven tiles tolerate the fallback because they also gate on a - // data floor; the achievements + recent-workouts cards have no floor, - // so wait for the real layout before committing them. - const layoutResolved = layoutData != null; - // v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself - // self-handles the loading skeleton + empty state (CTA → /achievements), - // so we only need the layout-toggle gate here. No data-floor check (the - // empty card is intentional — the maintainer wants the user to discover - // the feature). - const showAchievementsCard = layoutResolved && isChartVisible("achievements"); - // v1.4.32 — recent workouts dashboard tile. Self-gates on the - // workouts query response so we only need the layout toggle here; - // the tile renders an Apple-Health-onboarding hint when empty. Same - // layout-resolved gate as the achievements card — default-visible with - // no data floor, so it would otherwise flash in then retract for a user - // who hid it. - const showRecentWorkoutsTile = - layoutResolved && - isChartVisible("recentWorkouts") && - user?.modules?.workouts !== false; - - // v1.16.0 — shared reveal gate for the chart row. Every data-backed - // chart mounts as soon as its visibility gate flips (so the per-chart - // queries fan out in parallel), but the cells hold their layout-stable - // skeletons until EVERY gated chart reported its data settled — or the - // 2 s timeout fires so one slow widget cannot block the row (see - // `chart-reveal.tsx`). Pre-fix the cheap `/api/mood/analytics` read - // made the mood chart paint first and the measurement charts trickle - // in one after another. The id list mirrors the `charts[]` entry ids - // below; the achievements + recent-workouts cards stay outside the - // gate (they self-skeleton and carry no chart-shaped footprint). - const revealChartIds: string[] = []; - if (showWeightChart) { - revealChartIds.push("weight-chart"); - if (user?.heightCm) revealChartIds.push("bmi-chart"); + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. } - if (showBpCharts) revealChartIds.push("bp-chart"); - if (showPulseChart) revealChartIds.push("pulse-chart"); - if (showBodyFatChart) revealChartIds.push("bodyFat-chart"); - if (showMoodChart) revealChartIds.push("mood-chart"); - if (showSleepChart) revealChartIds.push("sleep-chart"); - if (showStepsChart) revealChartIds.push("steps-chart"); - if (showMedicationsCard) revealChartIds.push("medications"); - const { revealed: chartsRevealed, markReady: markChartReady } = - useDashboardChartReveal(revealChartIds); - // Glucose widget — visible iff layout enables it AND at least one reading exists. - // Glucose has no separate chart slot today, so the tile flag is the - // single source of truth for it. - const glucoseWidgetVisible = isTileVisible("glucose"); - const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); - const glucoseByContext = data?.glucoseByContext ?? {}; - const glucoseContexts = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", - ] as const; - const glucoseSummariesPresent = glucoseContexts.filter( - (ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0, - ); - const showGlucoseCards = - glucoseWidgetVisible && glucoseSummariesPresent.length > 0; - const glucoseLabelKey: Record = { - FASTING: "targets.glucoseFasting", - POSTPRANDIAL: "targets.glucosePostprandial", - RANDOM: "targets.glucoseRandom", - BEDTIME: "targets.glucoseBedtime", - }; - // v1.18.6 — band / target math is computed SERVER-side in the snapshot - // DTO (`targetBands`) so the client stops recomputing it from the - // profile (audit finding #3). When snapshot mode is on, read the - // resolved numbers straight from `snapshotQuery.data.targetBands`; the - // `!snapshotEnabled` branch keeps the legacy client-compute path so the - // non-snapshot fallback still works. The two produce byte-identical - // numbers (the server calls the SAME helpers — see - // `buildTargetBands` + its parity test). - const serverBands = snapshotEnabled - ? snapshotQuery.data?.targetBands - : undefined; - // Resolve the bands through the SAME `buildDashboardBands` helper the - // server snapshot uses, so the snapshot path and the client fallback - // are byte-identical by construction (no second inline copy of the - // band math to drift). Always compute the client copy from the profile - // — it is pure + cheap — so a still-loading snapshot frame keeps real - // bands rather than blank charts; `serverBands ?? clientBands` then - // prefers the authoritative server numbers the moment they arrive. - // Inputs only change with profile facts, so memoise the band math — it - // ran on every dashboard render (and got discarded by `serverBands ??` - // in the snapshot steady state) before this. Keyed on the `user` object - // identity so the React Compiler can preserve the memo (a narrower - // property list trips `preserve-manual-memoization`). - const clientBands = useMemo( - () => - buildDashboardBands({ - dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, - gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, - heightCm: user?.heightCm ?? null, - }), - [user], - ); - const bands = serverBands ?? clientBands; - const bpTargets = bands.bpTargets; - const weightRange = bands.weightRange; - const weightBands = bands.weightBands ?? undefined; - const bpTargetZones = bpTargets - ? [ - { - min: bpTargets.sysLow, - max: bpTargets.sysHigh, - color: "var(--chart-3)", - opacity: 0.21, - label: t("charts.systolic"), - textColor: "var(--chart-3)", - lineOpacity: 0.24, - }, - { - min: bpTargets.diaLow, - max: bpTargets.diaHigh, - color: "var(--chart-4)", - opacity: 0.21, - label: t("charts.diastolic"), - textColor: "var(--chart-4)", - lineOpacity: 0.24, - }, - ] - : undefined; - const bpSysRange = bands.bpSysRange; - const bpDiaRange = bands.bpDiaRange; - const pulseDisplayRange = bands.pulseDisplayRange; - const pulseBands = bands.pulseBands; - const bodyFatBands = bands.bodyFatBands; - - // v1.18.6 — ONE batched fetch for every visible non-sleep chart series - // (audit finding #2). Pre-fix, each `` fired its own - // `/api/measurements?...&source=rollup` round-trip, so a 6-8 chart - // dashboard issued 6-8 parallel requests on load. The batched endpoint - // returns all of them in one response; each chart reads its slice via - // `preloadedSeries` instead of self-fetching. Sleep stays on its - // dedicated per-night `/series` adapter (not rollup-backed), so it is - // never included here and the sleep chart keeps its own fetch. - // - // The window matches the charts' default 30-point view (the dashboard - // mounts them with no range-tab interaction); a chart whose window - // widens past this slice (range tab / comparison) drops batched - // coverage and self-fetches for the new window — see `usePreloaded`. - const batchTypeSet = new Set(); - if (showWeightChart) batchTypeSet.add("WEIGHT"); - if (showBpCharts) { - batchTypeSet.add("BLOOD_PRESSURE_SYS"); - batchTypeSet.add("BLOOD_PRESSURE_DIA"); + if (dehydratedState === null) { + return ; } - if (showPulseChart) - batchTypeSet.add(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE"); - if (showBodyFatChart) batchTypeSet.add("BODY_FAT"); - if (showStepsChart) batchTypeSet.add("ACTIVITY_STEPS"); - const batchChartTypes = Array.from(batchTypeSet); - - // Stable day-bucketed window so the cache key is stable across the day - // (mirrors the chart's own ISO-window stability contract). Computed - // once per mount via lazy state so a re-render never re-keys the batch. - const [batchWindow] = useState(() => { - const to = new Date(); - to.setHours(23, 59, 59, 999); - const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000); - return { from: from.toISOString(), to: to.toISOString() }; - }); - - const { data: batchedSeries } = useQuery({ - queryKey: queryKeys.chartSeriesBatch( - batchChartTypes.join(","), - batchWindow.from, - batchWindow.to, - ), - enabled: isAuthenticated && mounted && batchChartTypes.length > 0, - staleTime: 60_000, - gcTime: 5 * 60_000, - refetchOnWindowFocus: false, - queryFn: async () => { - const sp = new URLSearchParams(); - sp.set("types", batchChartTypes.join(",")); - sp.set("from", batchWindow.from); - sp.set("to", batchWindow.to); - const res = await apiGet<{ - series: Record< - string, - Array<{ - value: number; - measuredAt: string; - count?: number; - minValue?: number | null; - maxValue?: number | null; - }> - >; - }>(`/api/measurements/series-batch?${sp}`); - return res.series; - }, - }); - - // Per-chart slices in the chart's `MeasurementApiRow` shape. The batched - // rows already carry `measuredAt` / `value` / `count` / min / max — the - // exact fields the chart's bucketing loop reads. - const preloadedSeries = batchedSeries; - // v1.7.0 — the primary data source differs by flag. In snapshot - // mode `analyticsSlimQuery` is `enabled: false`, and a disabled - // TanStack query reports `fetchStatus: "idle"` → `isLoading` is - // always `false`. Gating the skeleton on the slim query then - // never fires under snapshot mode, so the empty-state branch - // below flashes for the whole snapshot fetch. Key the loading - // flag off whichever query is actually driving the tiles. - // v1.16.4 — `!mounted` pins the SSR pass AND the hydration - // render to the skeleton branch. Server-side the data queries - // are `enabled: false` (no auth) → `isLoading: false`, which - // used to select the empty state; a late-hydrating boundary - // then saw `isAuthenticated` already resolved and rendered the - // skeleton instead — React #418. See `useMounted`. - // v1.16.9 — hoisted out of the strip IIFE: the hero band below - // shares the same gate so its skeleton and the tile silhouettes - // swap to content in the same render pass. - const primaryLoading = - !mounted || - (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); - - // v1.16.9 — the greeting paragraph moved off the header into the - // hero band (it derives from the snapshot's server-computed - // `greetingHour`, so the old client-side Intl hour walk retired - // with it). The hero gates on the layout's `heroVisible` flag - // (opt-in; only the literal `true` renders) and holds - // a footprint-identical skeleton while the snapshot is in flight, - // so the band swaps in place — no growth, no greeting flash. When - // the hero does NOT render (toggle off, or snapshot flag off) the - // header takes the greeting back — it must never disappear with the - // band. - const heroVisible = snapshotEnabled && layout.heroVisible === true; - const heroSnapshot = snapshotQuery.data; - return ( -
- - - {heroVisible && - (primaryLoading || !heroSnapshot ? ( - - ) : ( - - ))} - - {/* v1.18.6 — the spotlight tour launcher moved to the app-shell - * (`AuthShell`) so its overlay survives the cross-page - * `router.push`es the module tour makes. The dashboard no longer - * mounts it. */} - - {/* Quick Entry Sheets — bottom-sheet on ` setQuickEntryDialog(null)} - /> - - {(() => { - type TrendEntry = { id: string; order: number; node: React.ReactNode }; - const trendCards: TrendEntry[] = []; - - if (showWeightTile) { - trendCards.push({ - id: "weight", - order: widgetOrder("weight"), - node: ( - - ), - }); - } - if (showBpTiles) { - // BP is conceptually one signal but visually two tiles (sys + dia) - // so the user sees both numbers side by side at the same size as - // every other tile in the strip. v1.4.3 first attempted a - // combined tile with `secondary` values — the maintainer preferred two - // distinct tiles with consistent symmetric widths. - trendCards.push({ - id: "bp-sys", - order: widgetOrder("bp"), - node: ( - - ), - }); - trendCards.push({ - id: "bp-dia", - // Sub-order keeps dia immediately after sys; the +0.001 leaves - // headroom for any future BP-related tile slotted between them. - order: widgetOrder("bp") + 0.001, - node: ( - - ), - }); - } - if (showPulseTile) { - trendCards.push({ - id: "pulse", - order: widgetOrder("pulse"), - node: ( - - ), - }); - } - if (showBodyFatTile) { - trendCards.push({ - id: "bodyFat", - order: widgetOrder("bodyFat"), - node: ( - - ), - }); - } - if (showMoodTile) { - trendCards.push({ - id: "mood", - order: widgetOrder("mood"), - node: ( - - ), - }); - } - if (showSleepTile) { - trendCards.push({ - id: "sleep", - order: widgetOrder("sleep"), - node: ( - - ), - }); - } - if (showStepsTile) { - trendCards.push({ - id: "steps", - order: widgetOrder("steps"), - node: ( - - ), - }); - } - // v1.4.25 W8d — VO2 max trend tile. Self-gates on the - // `vo2Max` widget being enabled (Settings → Dashboard) AND - // the analytics summary carrying at least one sample. Higher - // VO2 max is better, so the directionSentiment is up-good and - // an upward 30-day slope renders the green arrow. Unit - // matches the canonical DB unit in - // src/lib/validations/measurement.ts. - if (showVo2Tile) { - trendCards.push({ - id: "vo2Max", - order: widgetOrder("vo2Max"), - node: ( - - ), - }); - } - if (showBpInTargetTile) { - /* v1.4.22 A2 — feature parity with every other tile. - Synthesise a slope from the difference between the 7-day - and 30-day in-target shares: when the recent week is - above the recent month, the metric is improving (up-good - ⇒ green arrow); when below, it's slipping. The - trend7Delta is the same number as the arrow's underlying - signal, surfaced as "(+5)" next to `7d:` so the tile - matches the (weight / BP / pulse) call-site contract. - Comparison overlay routes through the same global - `compareBaseline` / `tileCompareDelta` pipeline as every - other tile; we only have a single % series for the BP - tile (no DataSummary) so the prior-period delta uses - `bpInTargetPctAllTime` as the long-arc baseline — when - comparison is off the field stays null. */ - // v1.4.28 FB-C1 + FB-C2 — rewrite the BD-Zielbereich tile - // against the shared `` primitive so it matches - // the Weight / BP / Pulse sibling tiles exactly. The - // synthetic `bpSlope30 = bpTrendDelta / 30` block produced a - // small fractional float that the TrendCard's date-shaped - // formatter pipeline rendered as "1.1." — the regression the - // maintainer flagged in the post-v1.4.27 walk-through. The - // all-time aggregate moves to the Insights blood-pressure - // target panel which already shows the same number with more - // context; the dashboard tile no longer needs to carry it. - // `avgAllTime` - // also retires from the TrendCard API (this was its only - // consumer). - const bp7 = data?.bpInTargetPct7d ?? null; - const bp30 = data?.bpInTargetPct30d ?? null; - const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null; - const bpPriorYear = data?.bpInTargetPctPriorYear ?? null; - const bpTrendDelta = - bp7 !== null && bp30 !== null ? bp7 - bp30 : null; - const bpComparePrior = - compareBaseline === "lastMonth" - ? bpPriorMonth - : compareBaseline === "lastYear" - ? bpPriorYear - : null; - const bpCompareDelta = - compareBaseline === "none" || - bp30 === null || - bpComparePrior === null - ? null - : Math.round((bp30 - bpComparePrior) * 10) / 10; - // v1.17 W1b — thin-data gate + dynamic span label. Below the - // confidence floor the tile narrates "collecting data" instead of - // a hard percentage, and the label names the EFFECTIVE span - // ("· 23 T" until ~90 days of history exist) rather than a - // dishonest static "· 90 T". When the count is unknown (snapshot - // still on the slim phase) the tile keeps the legacy 90-day label - // and shows whatever percentage arrived. - const bpCount90 = data?.bpInTargetCount90 ?? null; - const bpSpanDays90 = data?.bpInTargetSpanDays90 ?? null; - const bpSufficient = - bpCount90 === null || isWindowSufficient(bpCount90); - const bpLabel = - bpSpanDays90 != null - ? t("dashboard.bpInTargetWindowDynamic", { days: bpSpanDays90 }) - : t("dashboard.bpInTargetWindow90"); - trendCards.push({ - id: "bpInTarget", - order: widgetOrder("bpInTarget"), - node: ( - - ), - }); - } - if (showGlucoseCards) { - const glucoseOrder = widgetOrder("glucose"); - glucoseSummariesPresent.forEach((ctx, idx) => { - const s = glucoseByContext[ctx]; - trendCards.push({ - id: `glucose-${ctx}`, - // sub-order so all glucose cards stay in a block and order stable - order: glucoseOrder + idx / 1000, - node: ( - - ), - }); - }); - } - - trendCards.sort((a, b) => a.order - b.order); - - type ChartEntry = { - id: string; - order: number; - node: React.ReactNode; - /** - * Total raw readings for this metric. <5 surfaces a contextual - * "First trend after 5 readings" hint underneath the chart; - * undefined disables the hint (e.g. medications card). - */ - count?: number; - /** - * v1.16.0 — entry participates in the shared chart reveal: - * the cell holds its skeleton until every gated chart's data - * settled (or the 2 s fallback fires). Data-backed charts set - * this; the self-skeletoning achievements / recent-workouts - * cards do not. - */ - revealGated?: boolean; - }; - const charts: ChartEntry[] = []; - if (showWeightChart) { - charts.push({ - id: "weight-chart", - order: widgetOrder("weight"), - count: w?.count ?? 0, - revealGated: true, - node: ( - markChartReady("weight-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="weight" - types={["WEIGHT"]} - title={t("dashboard.weight")} - colors={["var(--chart-1)"]} - unit="kg" - valueBands={weightBands} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - /> - ), - }); - if (user?.heightCm) { - charts.push({ - id: "bmi-chart", - order: widgetOrder("weight") + 0.5, - revealGated: true, - node: ( - markChartReady("bmi-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bmi" - types={["WEIGHT"]} - title={t("targets.bmi")} - colors={["var(--dracula-yellow)"]} - unit="kg/m²" - valueMode="bmi" - valueBands={[ - { - min: 0, - max: 17, - color: "var(--destructive)", - opacity: 0.16, - }, - { - min: 17, - max: 18.5, - color: "var(--warning)", - opacity: 0.18, - }, - { - min: 18.5, - max: 24.9, - color: "var(--success)", - opacity: 0.2, - }, - { - min: 24.9, - max: 29.9, - color: "var(--warning)", - opacity: 0.18, - }, - { - min: 29.9, - max: 120, - color: "var(--destructive)", - opacity: 0.16, - }, - ]} - compareBaseline={compareBaseline} - /> - ), - }); - } - } - if (showBpCharts) { - charts.push({ - id: "bp-chart", - order: widgetOrder("bp"), - count: Math.max(sys?.count ?? 0, dia?.count ?? 0), - revealGated: true, - node: ( - markChartReady("bp-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bp" - types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} - title={t("dashboard.bloodPressure")} - colors={["var(--chart-3)", "var(--chart-4)"]} - unit="mmHg" - yAxisUnit="Hg" - targetZones={bpTargetZones} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - /> - ), - }); - } - if (showPulseChart) { - charts.push({ - id: "pulse-chart", - order: widgetOrder("pulse"), - count: p?.count ?? 0, - revealGated: true, - node: ( - markChartReady("pulse-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="pulse" - // v1.15.12 A2 — chart the RESTING series against the - // resting band when available; otherwise chart raw heart - // rate WITHOUT the resting-band overlay (it would mark - // expected-high workout HR as "outside target"). - types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]} - title={t("dashboard.pulse")} - colors={["var(--success)"]} - unit="bpm" - valueBands={hasRestingHr ? pulseBands : undefined} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - /> - ), - }); - } - if (showBodyFatChart) { - charts.push({ - id: "bodyFat-chart", - order: widgetOrder("bodyFat"), - count: bf?.count ?? 0, - revealGated: true, - node: ( - markChartReady("bodyFat-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="bodyFat" - types={["BODY_FAT"]} - title={t("dashboard.bodyFat")} - colors={["var(--warning)"]} - unit="%" - valueBands={bodyFatBands} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - /> - ), - }); - } - if (showMoodChart) { - charts.push({ - id: "mood-chart", - order: widgetOrder("mood"), - count: moodSummary?.count ?? 0, - revealGated: true, - node: ( - markChartReady("mood-chart")} - compareBaseline={compareBaseline} - chartKey="mood" - userTimezone={user?.timezone} - /> - ), - }); - } - if (showSleepChart) { - charts.push({ - id: "sleep-chart", - order: widgetOrder("sleep"), - count: sleepSummary?.count ?? 0, - revealGated: true, - node: ( - markChartReady("sleep-chart")} - chartKey="sleep" - types={["SLEEP_DURATION"]} - title={t("dashboard.sleep") ?? "Sleep"} - colors={["var(--chart-4)"]} - unit="h" - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - /> - ), - }); - } - if (showStepsChart) { - charts.push({ - id: "steps-chart", - order: widgetOrder("steps"), - count: stepsSummary?.count ?? 0, - revealGated: true, - node: ( - markChartReady("steps-chart")} - preloadedSeries={preloadedSeries} - preloadedCoverageDays={BATCH_COVERAGE_DAYS} - chartKey="steps" - types={["ACTIVITY_STEPS"]} - title={t("dashboard.steps") ?? "Steps"} - colors={["var(--success)"]} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - /> - ), - }); - } - if (showMedicationsCard) { - // v1.4.15 Fix 2: the toggle existed since v1.1 but the dashboard - // slot only rendered a static placeholder (icon + title), so - // flipping the layout switch on did nothing visible. Wire the - // real chart that consumes - // `/api/medications/intake?scope=compliance&days=N`. - charts.push({ - id: "medications", - order: widgetOrder("medications"), - revealGated: true, - node: ( - markChartReady("medications")} - compareBaseline={compareBaseline} - userTimezone={user?.timezone} - /> - ), - }); - } - if (showVorsorgeCard) { - // v1.18.2 — Vorsorge preventive-care summary card. Slotted via - // the layout `order` like medications; self-fetches its reminder - // list and self-skeletons, so it stays out of the reveal gate - // (no chart-shaped data-ready signal). - charts.push({ - id: "vorsorge", - order: widgetOrder("vorsorge"), - node: , - }); - } - if (showAchievementsCard) { - // v1.4.15 phase-B4 — slotted at the user's chosen position via - // the layout `order`. Default order from - // `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row) - // which matches the maintainer's brief "below the chart row". - charts.push({ - id: "achievements", - order: widgetOrder("achievements"), - node: , - }); - } - if (showRecentWorkoutsTile) { - // v1.4.32 — slotted via the same `order` mechanism. Self-gates - // on the workouts query response inside the tile itself. - charts.push({ - id: "recentWorkouts", - order: widgetOrder("recentWorkouts"), - node: , - }); - } - - charts.sort((a, b) => a.order - b.order); - - // v1.4.43 W11-M5 — tile-strip skeleton during slow slim-analytics - // fetches. - // - // `trendCards` only fills once the slim `/api/analytics` slice - // resolves and reports per-type `count > 0` flags. The v1.4.39.2 - // slim/thick split keeps the strip painting fast when slim wins, - // but when *both* slices lag (cache eviction, cold start) the - // user used to see the page header + 0 tiles + then 7 tiles - // appear at once 9 s later. The audit recommendation is to - // render a layout-stable tile silhouette keyed off the user's - // configured tile count so the strip's footprint is reserved - // during the slow window. The skeleton swaps in for the real - // strip the moment `analyticsSlimQuery.isLoading` flips false. - // v1.7.0 — count only WEB-known tiles. The stored layout now - // round-trips the 11 iOS-only ids (so the native client can drop - // its merge workarounds), but the web dashboard has no tile - // component for them; including them here would over-reserve the - // skeleton silhouette by rows that never paint. - // v1.16.8 — narrowed further to the tile-CAPABLE set (see - // `resolveConfiguredTileCount`): the old count included - // `medications` + `recentWorkouts` (chart-row cards with no - // strip tile) and undercounted `bp` (one widget id, two tiles), - // so the silhouettes reshuffled when the data landed. - const configuredTileCount = resolveConfiguredTileCount(layout); - // `primaryLoading` is derived once in the component body (above - // the return) — the hero band and this strip share the same - // gate so both surfaces swap in the same render pass. - // v1.16.8 — chart-row reservation while the snapshot is in - // flight. The chart visibility gates above need snapshot data - // (`count > 0`), so the cold chart row only carried the - // layout-gated medications card and then grew by ~1000 px when - // the snapshot landed. Reserve layout-stable ChartSkeleton - // cells for the charts the layout expects (minus the ones - // already mounted) so the page holds roughly its final - // footprint from first paint and the snapshot landing swaps - // content instead of growing the page. - // `mounted &&` pins the hydration render to the SSR output: the - // auth query is fired by the early-hydrating shell, so `user` - // can already carry a heightCm when this late-hydrating page - // boundary replays its first render — the extra BMI silhouette - // then mismatched the server HTML (React #418). The count may - // only personalise from the first client re-render. - const chartRowPlaceholderCount = primaryLoading - ? Math.max( - 0, - resolveChartRowPlaceholderCount(layout, { - hasHeightCm: mounted && Boolean(user?.heightCm), - }) - charts.length, - ) - : 0; - const { showTileStripSkeleton, showEmptyState } = - resolveDashboardFirstPaintGate({ - trendCardCount: trendCards.length, - chartCount: charts.length, - configuredTileCount, - primaryLoading, - }); - - // v1.4.15 phase-C5: dashboard fully-empty state. When no tile - // and no chart has data the dashboard would otherwise paint a - // 0-px tile strip with the welcome banner above it — visually - // looked like a half-broken page. Render an EmptyState that - // re-uses the existing quick-entry dialog so the user has a - // single click into "Log measurement" without leaving the page. - // The GettingStartedChecklist above renders its own self-gated - // surface for very-new accounts; this empty state covers the - // case where the checklist has been dismissed but no data was - // logged afterwards. - // - // v1.4.43 W11 — the empty-state only fires once the slim slice - // resolves with no tiles to show. While slim is in flight the - // skeleton strip below carries the layout footprint. - if (showEmptyState) { - return ( - } - title={t("dashboard.emptyTitle")} - description={t("dashboard.emptyDescription")} - action={ - - } - /> - ); - } - - return ( - <> - {/* v1.4: dashboard tiles are *always* a single row. - * Maintainer-explicit (per feedback_dashboard_one_row.md): a 2-row - * tile strip breaks the visual hierarchy and reads like an - * Excel grid. Total width caps at the parent container — - * exactly the chart-width below. When the active tile count - * exceeds what fits the viewport, the strip horizontal-scrolls - * instead of wrapping; the user trims the set in - * Settings → Dashboard (`/settings/dashboard`). - * Each tile keeps a `min-w-[10rem]` so a single tile still - * looks substantial on a wide screen, and `snap-x snap-mandatory` - * makes the scroll feel deliberate rather than arbitrary on - * touch. - */} - {/* v1.4.16 Fix A5: hide the strip entirely when the user - turned off every tile. Until v1.4.15 the wrapper rendered - an empty grid even with zero tiles — visually a thin gap - the maintainer described as "awkward". Charts below still render so - the page is not empty; the tile-strip just goes away. - The constraint the maintainer named — "immer die gesamte Spalte - breit und immer der gleichen Höhe" — is preserved by the - CSS-grid `auto-fit + minmax + auto-rows-fr` track that - continues to give every visible tile equal width / equal - height for any non-zero count. */} - {showTileStripSkeleton && ( - - )} - {trendCards.length > 0 && ( -
- {trendCards.map((entry) => ( -
- {/* - * Per-tile `` boundary with a layout-stable - * placeholder that mirrors the trend-card chrome. Tile - * bodies are synchronous today so the fallback rarely - * paints, but a future RSC hoist of any tile slot - * would otherwise leave the grid track empty and - * trigger CLS as the cell paints in. - * - * v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder. - */} - {/* v1.16.0 — the fallback is the same structured - silhouette the tile-strip skeleton paints, so a - suspending tile slot previews the final shape - instead of an empty card. */} - }> - {entry.node} - -
- ))} -
- )} - {charts.map((entry) => ( -
- {/* - * v1.4.40 W-RSC — per-tile `` boundary so each - * chart paints independently rather than the row blocking - * on the slowest fetch (audit-H2 + brief C1). The - * `next/dynamic({ ssr: false })` lazy-load contract on - * `HealthChartDynamic` / `MoodChart` / - * `MedicationComplianceChart` already paints a - * `` while the JS chunk is in flight; this - * Suspense layer lifts the same skeleton to a streaming- - * compatible boundary so a future migration to - * `useSuspenseQuery` (when we replace the per-chart - * `["chart-data", …]` fetches with the slim-analytics- - * derived store) automatically buckets each chart's - * loading state to its own cell, with no further - * call-site changes. - * - * Today, the boundary is a structural no-op for the - * dynamic-loaded charts because their loading skeleton - * lives inside the dynamic factory. The benefit is - * future-proofing the composition: any descendant that - * later suspends (e.g. an RSC migration of a static - * legend, or a server-streamed sparkline) gets its own - * fallback without re-architecting the row. - */} - {entry.revealGated ? ( - /* v1.16.0 — gated cell: the chart mounts (its query - fires) but stays visually on the layout-stable - skeleton until the shared reveal flips — all gated - charts swap in the same frame with one short - fade-in (motion-safe). The TrendHint rides inside - the cell so it appears with its chart. */ - - }> - {entry.node} - - {entry.count != null ? ( - - ) : null} - - ) : ( - <> - }> - {entry.node} - - {entry.count != null ? ( - - ) : null} - - )} -
- ))} - {/* v1.16.8 — layout-stable chart-row reservation. While the - snapshot is in flight the data-gated chart entries above - cannot exist yet, so these silhouettes carry the row's - footprint; the moment the snapshot lands the real cells - render in their place (same render pass — no growth, a - swap). `aria-hidden` like the tile-strip skeleton: the - mounted charts announce their own loading state. */} - {chartRowPlaceholderCount > 0 && ( - - )} - - ); - })()} - - {/* v1.4: Getting-started checklist for brand-new users. Self-gates - * visibility on (onboardingCompletedAt == null || measurementCount - * < 5) and disappears once dismissed or fully complete. - * v1.16.8 — moved BELOW the tile strip + chart row: the card only - * mounts once the snapshot resolves, and sitting above the strip - * it pushed every tile and chart down when it popped in. At the - * bottom its late arrival shifts nothing — the fold-priority - * content (tiles, charts) keeps a stable position, and the - * near-empty dashboards of the new accounts the card targets keep - * it visible without scrolling anyway. */} - -
+ + + ); } diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts new file mode 100644 index 000000000..3570ad691 --- /dev/null +++ b/src/lib/dashboard/snapshot-read.ts @@ -0,0 +1,95 @@ +/** + * Shared cached-snapshot read for the two dashboard entry points: + * + * - `GET /api/dashboard/snapshot` (the client cell's endpoint), and + * - the dashboard RSC wrapper (`src/app/page.tsx`), which server- + * prefetches the same payload into a dehydrated TanStack cache so the + * first HTML paints real tiles instead of skeletons-until-JS. + * + * Both ride the SAME `caches.analytics` SWR cell (key + * `${userId}|dashboard-snapshot|${locale}`), so an RSC prefetch warms the + * API path and vice versa — the builder never runs twice for one user + * within the TTL, and the write-invalidation semantics (stale-sweep on + * measurement writes, hard evict on widget reorder) cover both readers. + */ +import type { User } from "@/generated/prisma/client"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; +import { + buildDashboardSnapshot, + type DashboardSnapshot, + type SnapshotUserInput, +} from "@/lib/dashboard/snapshot"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; + +/** + * Per-key TTL for the snapshot cache entry. Strictly greater than the + * client's 120 s refetch interval so a scheduled poll lands on a warm + * entry instead of a guaranteed miss that re-runs the full builder. The + * 60 s headroom absorbs interval jitter / a poll firing a touch late. + * The analytics bucket's 60 s default still governs the slim / thick / + * mood cells; only this key is lengthened. Eviction on writes is + * unchanged (the `${userId}|` prefix sweep / point-delete both ignore + * the TTL). + */ +export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; + +export interface SnapshotReadResult { + body: DashboardSnapshot; + /** The locale the snapshot was resolved (and cache-keyed) under. */ + locale: string; +} + +/** + * Resolve locale + read the snapshot through the SWR cache for an already + * authenticated user row. `time` lets the API route keep its per-sub-query + * timing surface; the RSC prefetch passes none. + */ +export async function readDashboardSnapshotCached( + user: User, + time?: (label: string, builder: () => Promise) => Promise, +): Promise { + // `User` row is already in hand at both call sites — no extra round-trip. + const snapshotUser: SnapshotUserInput = { + id: user.id, + username: user.username, + displayName: user.displayName, + timezone: user.timezone, + heightCm: user.heightCm, + dateOfBirth: user.dateOfBirth, + gender: user.gender, + glucoseUnit: user.glucoseUnit, + onboardingTourCompleted: user.onboardingTourCompleted, + disableCoach: user.disableCoach, + insightsCachedText: user.insightsCachedText, + insightsCachedAt: user.insightsCachedAt, + dashboardWidgetsJson: user.dashboardWidgetsJson, + }; + + // v1.21.2 (A4) — the briefing recall + forward-look is locale-specific + // prose, so the snapshot cache key carries the resolved locale: an EN and + // a DE session never share a snapshot cell (and never see each other's + // memory wording). The `${user.id}|` prefix still covers the key under + // the measurement-write stale-sweep. + const locale = await resolveServerLocale({ userLocale: user.locale }); + + // Stale-while-revalidate: a measurement write marks the analytics + // bucket stale rather than hard-evicting the snapshot, so a busy iOS + // sync serves the prior snapshot immediately (within the bucket's + // stale window) while a single background recompute warms a fresh one + // — the foreground request never pays the cold rebuild. Hard-evicting + // writes (widget reorder) still drop the key outright, forcing a clean + // miss + synchronous rebuild here. + const body = await cachedSwr( + caches.analytics as ServerCache, + `${user.id}|dashboard-snapshot|${locale}`, + () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), + annotate, + SNAPSHOT_CACHE_TTL_MS, + ); + + return { body, locale }; +}