From 271abd7b640b21a94581d48a8f00c5f5b505aef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 14:37:49 +0200 Subject: [PATCH 1/3] perf(dashboard): server-prefetch the Today hero + chart series into the dehydrated cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kill the above-the-fold first-load flash on `/`: the hero digest and the batched chart series still fired client-side after JS download + hydrate, so the hero and charts painted empty and filled in seconds later (LCP ~5.5 s mobile-throttled while the snapshot already dehydrated). Extend the existing `page.tsx` snapshot prefetch to also seed the daily digest and the chart series batch into the same dehydrated TanStack cache, under keys byte-identical to the client's: - Extract the series-batch route core into `readSeriesBatch(...)` so the RSC and the route share one code path (no HTTP-to-self); the route thins to auth + Zod + the shared reader, behaviour unchanged. - New client-safe pure module `batch-chart-types.ts`: `deriveBatchChartTypes` (the single implementation of the visible-with-data type-set derivation, order + resting-HR-vs-pulse pick preserved) and `computeBatchWindow` (profile-tz end-of-day minus the coverage window). Server and client run the same function over the same snapshot payload, and the window is computed once server-side and threaded to the client as a prop, so the `chartSeriesBatch` key matches on both sides by construction. - Render the hero data-first so the SSR HTML carries the populated hero (the LCP element) — hydration-safe because the digest is dehydrated and never restored from the IndexedDB persist allowlist; bound that invariant with comments on both the hero gate and the allowlist. - Warm the shared chart-runtime chunk during hydration. Both prefetches honour the `DASHBOARD_SSR_PREFETCH=false` escape and fail soft to the client-fetch path on any error; a ~400 ms soft budget bounds the cold-day digest cost. No contract change, no visual change. --- .../api/measurements/series-batch/route.ts | 63 +---- src/app/page-client.tsx | 106 ++++++-- src/app/page.tsx | 143 ++++++++-- .../__tests__/batch-chart-types.test.ts | 257 ++++++++++++++++++ src/lib/dashboard/batch-chart-types.ts | 139 ++++++++++ src/lib/measurements/series-batch-read.ts | 80 ++++++ src/lib/pwa/query-persister.ts | 8 + src/lib/tz/format.ts | 2 +- 8 files changed, 691 insertions(+), 107 deletions(-) create mode 100644 src/lib/dashboard/__tests__/batch-chart-types.test.ts create mode 100644 src/lib/dashboard/batch-chart-types.ts create mode 100644 src/lib/measurements/series-batch-read.ts diff --git a/src/app/api/measurements/series-batch/route.ts b/src/app/api/measurements/series-batch/route.ts index 00f1ace89..15b57ae96 100644 --- a/src/app/api/measurements/series-batch/route.ts +++ b/src/app/api/measurements/series-batch/route.ts @@ -22,28 +22,15 @@ * client keeps sleep on the dedicated route. */ import { NextRequest } from "next/server"; -import pLimit from "p-limit"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { returnAllZodIssues } from "@/lib/api-response"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { - readDailySeries, - type DailySeriesRow, -} from "@/lib/measurements/daily-series-read"; +import { readSeriesBatch } from "@/lib/measurements/series-batch-read"; import { seriesBatchQuerySchema, SERIES_BATCH_MAX_TYPES, } from "@/lib/validations/series-batch"; -import type { MeasurementType } from "@/generated/prisma/client"; - -/** - * Concurrency cap for the per-type rollup reads. Mirrors the W-POOL - * `p-limit(4)` discipline applied across the analytics fan-out so a - * wide dashboard never packs the shared Prisma pool. - */ -const SERIES_BATCH_CONCURRENCY = 4; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -55,49 +42,23 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { types, from, to } = parsed.data; - // De-dupe while preserving order so a caller repeating a type doesn't - // double-read it. - const uniqueTypes = Array.from(new Set(types)) as MeasurementType[]; - // Load the source-priority ladder ONCE and thread it across every type - // so the batched reader doesn't re-query it per type. - const priorityJson = await loadUserSourcePriority(user.id); - - // Per-type isolation: a single type's transient DB reject degrades to an - // empty slice for THAT type only, never a 500 that blanks every chart in - // the row. The failing chart self-fetches through its existing fallback. - const limit = pLimit(SERIES_BATCH_CONCURRENCY); - let degradedTypes = 0; - const entries = await Promise.all( - uniqueTypes.map((type) => - limit(async (): Promise => { - try { - const rows = await readDailySeries({ - userId: user.id, - type, - from, - to, - priorityJson, - }); - return [type, rows] as const; - } catch { - degradedTypes += 1; - return [type, []] as const; - } - }), - ), + // Shared core: priority ladder once, `p-limit(4)` per-type fan-out, per-type + // fault isolation to `[]`. The RSC dashboard prefetch calls the same + // function so the two paths never drift. + const { series, degradedTypes } = await readSeriesBatch( + user.id, + types, + from, + to, ); - const series: Record = {}; - for (const [type, rows] of entries) { - series[type] = rows; - } - + const seriesLists = Object.values(series); annotate({ action: { name: "measurement.series.batch" }, meta: { - type_count: uniqueTypes.length, - total: entries.reduce((sum, [, rows]) => sum + rows.length, 0), + type_count: seriesLists.length, + total: seriesLists.reduce((sum, rows) => sum + rows.length, 0), degraded_types: degradedTypes, }, }); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index d607fdf7c..0b8e4af10 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -87,10 +87,12 @@ const DASHBOARD_QUERY_OPTS = { // 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; +// the 90 / All tabs exceed it and self-fetch the wider window. +// +// v1.30.9 — `BATCH_COVERAGE_DAYS` + the type-set + window derivations moved to +// the client-safe `@/lib/dashboard/batch-chart-types` module so the RSC +// prefetch (page.tsx) and this client read the SAME code — the batched-series +// cache key is byte-identical on both sides or the prefetch silently no-ops. // v1.16.8 — the loaders retry a rejected chunk import once (a lazy // import caches its rejection permanently, so a transient 404 from a @@ -123,6 +125,17 @@ const MedicationComplianceChart = ( ); + +// v1.30.9 — warm the shared `chart-runtime` chunk during hydration. The +// dashboard charts mount through `ssr:false` dynamics, which begin their +// import only at mount (post-hydration); firing the import at module +// evaluation lets the chunk download overlap hydration instead of queueing +// behind the first chart mount. No render-path change — the dynamics still +// resolve through the same `importWithRetry` promise (a warm module cache), +// and the `` fallback is untouched. +if (typeof window !== "undefined") { + void importWithRetry(() => import("@/components/charts/chart-runtime")); +} import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; @@ -130,6 +143,12 @@ import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { useDailyDigest } from "@/lib/queries/use-daily-digest"; import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; +import { + BATCH_COVERAGE_DAYS, + deriveBatchChartTypes, + computeLocalBatchWindow, + type BatchWindow, +} from "@/lib/dashboard/batch-chart-types"; import type { DataSummary } from "@/lib/analytics/trends"; import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; import { isWindowSufficient } from "@/lib/analytics/window-confidence"; @@ -187,7 +206,20 @@ function tileInsightHref(id: string): string | null { return TILE_INSIGHT_HREF[id] ?? null; } -export default function DashboardPageClient() { +export default function DashboardPageClient({ + batchWindow: batchWindowProp, +}: { + /** + * v1.30.9 — the batched-series ISO window computed server-side from the + * PROFILE timezone and threaded down as an RSC prop. Present only when the + * RSC prefetch ran (snapshot mode, `DASHBOARD_SSR_PREFETCH !== "false"`); + * absent on the legacy / prefetch-off / fail-soft paths, where the client + * falls back to its browser-local window. Adopting the prop verbatim keeps + * the `chartSeriesBatch` key byte-identical to the key the RSC dehydrated + * under, so the prefetched slice lands instead of triggering a refetch. + */ + batchWindow?: BatchWindow; +} = {}) { const { isAuthenticated, user } = useAuth(); const mounted = useMounted(); const { t } = useTranslations(); @@ -723,27 +755,28 @@ export default function DashboardPageClient() { // 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); + // + // v1.30.9 — the type-set derivation is the shared `deriveBatchChartTypes`, + // the SAME function the RSC prefetch runs over the SAME snapshot payload + // (`layout` + `data.summaries` in snapshot mode ARE the snapshot fields the + // server dehydrated). One implementation → the CSV, and therefore the + // batched-series cache key, is byte-identical on both sides by construction. + const batchChartTypes = deriveBatchChartTypes(layout, data?.summaries); // Stable day-bucketed window so the cache key is stable across the day // (mirrors the chart's own ISO-window stability contract). Computed // once per mount via lazy state so a re-render never re-keys the batch. - 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() }; - }); + // + // v1.30.9 — the server-computed PROFILE-tz window wins when present (it + // rides the serialized RSC payload, so SSR and the first hydration render + // see the identical object → the `chartSeriesBatch` key matches the key the + // RSC dehydrated under, zero hydration seam). The browser-local fallback + // stands for the prefetch-off / legacy / fail-soft paths where no prop + // arrives. Lazy `useState` with a prop-derived initial value is + // deterministic across the SSR/CSR pair. + const [batchWindow] = useState( + () => batchWindowProp ?? computeLocalBatchWindow(), + ); const { data: batchedSeries } = useQuery({ queryKey: queryKeys.chartSeriesBatch( @@ -815,17 +848,30 @@ export default function DashboardPageClient() { falling through to nothing. The dense tile grid + charts below are untouched. */} {insightsEnabled && - // `!mounted` pins the SSR pass AND the first hydration render to the - // skeleton (v1.16.4 pattern): the digest query rehydrates from the - // persisted cache on the client, so without this gate the client's - // first render would paint the hero while the server rendered the - // skeleton — a text-content hydration mismatch (React #418). - (!mounted || digestQuery.isLoading ? ( + // v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero + // (the LCP element) instead of a skeleton. This is hydration-safe ONLY + // because of a bound invariant: the digest is server-DEHYDRATED + // (`page.tsx` prefetch → `setQueryData(queryKeys.dailyDigest(), …)`), + // so `digestQuery.data` is present on BOTH the SSR pass AND the first + // hydration render (HydrationBoundary hydrates synchronously before + // first render; `enabled:false` only gates FETCHING, not cached-data + // reads) — both sides render identical HTML, no React #418. + // + // The one divergence class the old `!mounted` gate guarded against — + // a client-only IndexedDB-restored cache painting the hero while the + // server rendered a skeleton — CANNOT bite here: `["daily", …]` is NOT + // in the persist allowlist (`PERSIST_ALLOWLIST_HEADS` in + // `@/lib/pwa/query-persister` — `dashboard` / `chart-data` + the exact + // `["user","dashboardWidgets"]` tuple only), so the digest never + // rehydrates from disk. The error / empty branches stay reachable only + // post-mount. DO NOT add `["daily", …]` to that allowlist, or render + // the hero from any client-only source, without revisiting this gate. + (digestQuery.data ? ( + + ) : !mounted || digestQuery.isLoading ? ( ) : digestQuery.isError ? ( digestQuery.refetch()} /> - ) : digestQuery.data ? ( - ) : null)} {/* v1.18.6 — the spotlight tour launcher moved to the app-shell diff --git a/src/app/page.tsx b/src/app/page.tsx index 291a8057d..f06433fdf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,52 +6,83 @@ import { import { getSession } from "@/lib/auth/session"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; +import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; +import { + computeBatchWindow, + deriveBatchChartTypes, + type BatchWindow, +} from "@/lib/dashboard/batch-chart-types"; +import { resolveDashboardLayout } from "@/lib/dashboard-layout"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { readSeriesBatch } from "@/lib/measurements/series-batch-read"; import { queryKeys } from "@/lib/query-keys"; +import type { MeasurementType } from "@/generated/prisma/client"; import DashboardPageClient from "./page-client"; +/** + * Soft budget on the cold-day digest prefetch. `loadDailyDigest`'s only + * heavy input is the `daily-digest-extras` SWR cell: on the FIRST digest read + * of a user's local day it rebuilds the streak scan + intraday-pulse read cold + * (low hundreds of ms). Racing it against this budget bounds the worst-case + * TTFB regression — the abandoned work still runs to completion and warms the + * SWR cell, so the client fetch that follows a skipped dehydrate lands warm. + */ +const DIGEST_SOFT_BUDGET_MS = 400; + +/** Resolve `undefined` after `ms` — the soft-budget loser in the digest race. */ +function softTimeout(ms: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(undefined), ms)); +} + /** * 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. + * because the real above-the-fold content waited for the full JS download + + * hydrate + CLIENT-side fetches. This wrapper runs the same cached reads the + * routes use during SSR and hands them to TanStack through `HydrationBoundary`, + * so the first HTML already carries real content and the mounted cells start + * warm instead of skeleton-first: + * + * - the dashboard snapshot (`/api/dashboard/snapshot`) — every tile; + * - the Today digest (`/api/daily/digest` → `loadDailyDigest`) — the hero; + * - the batched chart series (`/api/measurements/series-batch` → + * `readSeriesBatch`) — the chart row. * * 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. + * - Query keys come ONLY from the central factory. Every dehydrated VALUE 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 poisons the cell. + * - The batched-series key is the crux: the CSV type list comes from the + * SHARED `deriveBatchChartTypes` over the SAME snapshot payload, and the ISO + * window from `computeBatchWindow(now, user.timezone)` threaded to the + * client as the `batchWindow` prop. Server and client build the identical + * key by construction, so the prefetched slice lands instead of refetching. + * - Module-gate parity: the digest route gates on `insights`; skip its + * prefetch when the module is off (the client hook is disabled then too). + * - Fail-soft: no session, a builder hiccup, or a DB blip renders the page + * exactly as before this wrapper existed — the client cells own the fetch. + * Each prefetch is independent; one failing never blocks the others. */ 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. + // keep governing what the dashboard paints. It gates EVERY prefetch below. if (process.env.DASHBOARD_SSR_PREFETCH === "false") { return ; } let dehydratedState = null; + let batchWindow: BatchWindow | undefined; try { const session = await getSession(); if (session) { - const { body, locale } = await readDashboardSnapshotCached(session.user); + const { user } = session; + const { body, locale } = await readDashboardSnapshotCached(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< @@ -61,6 +92,68 @@ export default async function DashboardPage() { const queryClient = new QueryClient(); queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody); queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout); + + // Above-the-fold prefetch — the profile-tz window is computed ONCE and + // threaded to the client so the batched-series key matches byte-for-byte. + batchWindow = computeBatchWindow(new Date(), user.timezone); + + const modules = await resolveModuleMap(user.id); + const insightsEnabled = modules.insights !== false; + + // Only prefetch the series in snapshot mode: legacy mode derives its + // type list from analytics queries the RSC did not run, so there is no + // byte-identical key to dehydrate under — the client fetches as before. + const seriesTypes = isDashboardSnapshotEnabled() + ? deriveBatchChartTypes( + resolveDashboardLayout(wireBody.layout), + ( + wireBody.tiles as { + summaries?: Record; + } + )?.summaries, + ) + : []; + + // Digest carries a soft budget (cold-day extras rebuild); series is a + // warm rollup read (< 100 ms) with none. Both fail soft to `undefined`. + const digestWork = insightsEnabled + ? loadDailyDigest(user).catch(() => undefined) + : Promise.resolve(undefined); + const seriesWork = + seriesTypes.length > 0 + ? readSeriesBatch( + user.id, + seriesTypes as MeasurementType[], + new Date(batchWindow.from), + new Date(batchWindow.to), + ) + .then((result) => result.series) + .catch(() => undefined) + : Promise.resolve(undefined); + + const [digest, series] = await Promise.all([ + Promise.race([digestWork, softTimeout(DIGEST_SOFT_BUDGET_MS)]), + seriesWork, + ]); + + // JSON-round-trip both to the client wire shape before seeding. + if (digest !== undefined) { + queryClient.setQueryData( + queryKeys.dailyDigest(), + JSON.parse(JSON.stringify(digest)), + ); + } + if (series !== undefined) { + queryClient.setQueryData( + queryKeys.chartSeriesBatch( + seriesTypes.join(","), + batchWindow.from, + batchWindow.to, + ), + JSON.parse(JSON.stringify(series)), + ); + } + dehydratedState = dehydrate(queryClient); } } catch { @@ -68,11 +161,11 @@ export default async function DashboardPage() { } if (dehydratedState === null) { - return ; + return ; } return ( - + ); } diff --git a/src/lib/dashboard/__tests__/batch-chart-types.test.ts b/src/lib/dashboard/__tests__/batch-chart-types.test.ts new file mode 100644 index 000000000..7fb7cad0b --- /dev/null +++ b/src/lib/dashboard/__tests__/batch-chart-types.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect } from "vitest"; + +import { + BATCH_COVERAGE_DAYS, + computeBatchWindow, + computeLocalBatchWindow, + deriveBatchChartTypes, +} from "@/lib/dashboard/batch-chart-types"; +import { + resolveDashboardLayout, + type DashboardLayout, + type DashboardWidgetConfig, +} from "@/lib/dashboard-layout"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.30.9 — the dashboard batched-series prefetch key crux. + * + * `deriveBatchChartTypes` + `computeBatchWindow` are the SINGLE source of the + * two moving parts of the `chartSeriesBatch(csv, from, to)` cache key. The RSC + * prefetch (server) and the dashboard client both call them over the SAME + * snapshot payload / profile window; if they ever disagree the dehydrated + * slice is dead weight and the client refetches. These tests pin the ordered + * type derivation, the resting-HR-vs-pulse pick, the profile-tz window, and + * the byte-identical key both sides build. + */ + +/** Build a resolved-shaped layout with exactly the given widget visibility. */ +function layoutOf(visible: Record): DashboardLayout { + const widgets: DashboardWidgetConfig[] = Object.entries(visible).map( + ([id, v], i) => + ({ + id, + visible: v, + order: i, + }) as DashboardWidgetConfig, + ); + return { version: 1, widgets }; +} + +const ALL_VISIBLE = { + weight: true, + bp: true, + pulse: true, + bodyFat: true, + steps: true, +}; + +describe("deriveBatchChartTypes", () => { + it("emits every visible type with data in insertion order", () => { + const types = deriveBatchChartTypes(layoutOf(ALL_VISIBLE), { + WEIGHT: { count: 5 }, + BLOOD_PRESSURE_SYS: { count: 3 }, + BLOOD_PRESSURE_DIA: { count: 3 }, + PULSE: { count: 9 }, + BODY_FAT: { count: 2 }, + ACTIVITY_STEPS: { count: 30 }, + }); + expect(types).toEqual([ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "ACTIVITY_STEPS", + ]); + }); + + it("prefers RESTING_HEART_RATE over PULSE when resting rows exist", () => { + const types = deriveBatchChartTypes(layoutOf({ pulse: true }), { + PULSE: { count: 40 }, + RESTING_HEART_RATE: { count: 12 }, + }); + expect(types).toEqual(["RESTING_HEART_RATE"]); + }); + + it("falls back to PULSE when only raw pulse rows exist", () => { + const types = deriveBatchChartTypes(layoutOf({ pulse: true }), { + PULSE: { count: 40 }, + RESTING_HEART_RATE: { count: 0 }, + }); + expect(types).toEqual(["PULSE"]); + }); + + it("shows the pulse chart on resting rows even with zero raw pulse", () => { + const types = deriveBatchChartTypes(layoutOf({ pulse: true }), { + RESTING_HEART_RATE: { count: 7 }, + }); + expect(types).toEqual(["RESTING_HEART_RATE"]); + }); + + it("emits both BP legs when either sys or dia has data", () => { + const types = deriveBatchChartTypes(layoutOf({ bp: true }), { + BLOOD_PRESSURE_SYS: { count: 4 }, + BLOOD_PRESSURE_DIA: { count: 0 }, + }); + expect(types).toEqual(["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]); + }); + + it("excludes a hidden chart even with data (visibility floor)", () => { + const types = deriveBatchChartTypes( + layoutOf({ weight: false, steps: true }), + { WEIGHT: { count: 5 }, ACTIVITY_STEPS: { count: 5 } }, + ); + expect(types).toEqual(["ACTIVITY_STEPS"]); + }); + + it("excludes a visible chart with no data (count floor)", () => { + const types = deriveBatchChartTypes(layoutOf(ALL_VISIBLE), { + WEIGHT: { count: 0 }, + BODY_FAT: { count: 1 }, + }); + expect(types).toEqual(["BODY_FAT"]); + }); + + it("returns an empty list when nothing is visible-with-data", () => { + expect(deriveBatchChartTypes(layoutOf(ALL_VISIBLE), {})).toEqual([]); + expect(deriveBatchChartTypes(layoutOf(ALL_VISIBLE), undefined)).toEqual([]); + }); + + it("is insensitive to summaries key order (stable insertion order out)", () => { + const a = deriveBatchChartTypes(layoutOf(ALL_VISIBLE), { + ACTIVITY_STEPS: { count: 1 }, + WEIGHT: { count: 1 }, + BODY_FAT: { count: 1 }, + }); + expect(a).toEqual(["WEIGHT", "BODY_FAT", "ACTIVITY_STEPS"]); + }); +}); + +describe("computeBatchWindow", () => { + it("ends the window at profile-tz end-of-day and spans BATCH_COVERAGE_DAYS", () => { + const now = new Date("2026-07-15T12:00:00.000Z"); // 08:00 EDT + const win = computeBatchWindow(now, "America/New_York"); + // 2026-07-15 23:59:59.999 -04:00 === 2026-07-16T03:59:59.999Z + expect(win.to).toBe("2026-07-16T03:59:59.999Z"); + expect(new Date(win.to).getTime() - new Date(win.from).getTime()).toBe( + BATCH_COVERAGE_DAYS * 86_400_000, + ); + }); + + it("handles a half-hour-offset zone east of UTC", () => { + const now = new Date("2026-07-15T20:00:00.000Z"); // 01:30 next day IST + const win = computeBatchWindow(now, "Asia/Kolkata"); + // local day 2026-07-16, end 23:59:59.999 +05:30 === 2026-07-16T18:29:59.999Z + expect(win.to).toBe("2026-07-16T18:29:59.999Z"); + }); + + it("computes a plain UTC end-of-day", () => { + const now = new Date("2026-07-15T12:00:00.000Z"); + const win = computeBatchWindow(now, "UTC"); + expect(win.to).toBe("2026-07-15T23:59:59.999Z"); + }); + + it("always includes today (to >= now) regardless of zone", () => { + const now = new Date("2026-07-15T23:30:00.000Z"); + for (const tz of ["UTC", "America/New_York", "Pacific/Auckland"]) { + const win = computeBatchWindow(now, tz); + expect(new Date(win.to).getTime()).toBeGreaterThanOrEqual(now.getTime()); + } + }); + + it("is deterministic for the same now + tz (server prop === client adoption)", () => { + const now = new Date("2026-07-15T12:00:00.000Z"); + expect(computeBatchWindow(now, "Europe/Berlin")).toEqual( + computeBatchWindow(now, "Europe/Berlin"), + ); + }); + + it("falls back safely on an unusable timezone id", () => { + const now = new Date("2026-07-15T12:00:00.000Z"); + const win = computeBatchWindow(now, "Not/AZone"); + expect(typeof win.to).toBe("string"); + expect(new Date(win.to).getTime() - new Date(win.from).getTime()).toBe( + BATCH_COVERAGE_DAYS * 86_400_000, + ); + }); +}); + +describe("computeLocalBatchWindow", () => { + it("matches the legacy browser-local end-of-day computation", () => { + const now = new Date("2026-07-15T09:00:00.000Z"); + const win = computeLocalBatchWindow(now); + const to = new Date(now); + to.setHours(23, 59, 59, 999); + const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000); + expect(win).toEqual({ from: from.toISOString(), to: to.toISOString() }); + }); +}); + +describe("the batched-series key crux — server key === client key", () => { + // A fixture snapshot payload: the SAME object the RSC dehydrates and the + // client hydrates. Server and client each run the shared helpers over it. + const snapshot = { + layout: { + version: 1, + widgets: [ + { id: "weight", visible: true, order: 0 }, + { id: "bp", visible: true, order: 1 }, + { id: "pulse", visible: true, order: 2 }, + { id: "bodyFat", visible: false, order: 3 }, + { id: "steps", visible: true, order: 4 }, + ], + }, + tiles: { + summaries: { + WEIGHT: { count: 12 }, + BLOOD_PRESSURE_SYS: { count: 6 }, + BLOOD_PRESSURE_DIA: { count: 6 }, + RESTING_HEART_RATE: { count: 4 }, + PULSE: { count: 20 }, + BODY_FAT: { count: 3 }, + ACTIVITY_STEPS: { count: 40 }, + }, + }, + }; + const now = new Date("2026-07-15T12:00:00.000Z"); + const tz = "America/New_York"; + + it("derives the identical chartSeriesBatch key on both sides", () => { + // Server side: resolve the round-tripped snapshot layout + summaries. + const wire = JSON.parse(JSON.stringify(snapshot)); + const serverTypes = deriveBatchChartTypes( + resolveDashboardLayout(wire.layout), + wire.tiles.summaries, + ); + const serverWindow = computeBatchWindow(now, tz); + const serverKey = queryKeys.chartSeriesBatch( + serverTypes.join(","), + serverWindow.from, + serverWindow.to, + ); + + // Client side: same helpers over the same (live) snapshot, adopting the + // server window verbatim as the `batchWindow` prop. + const clientTypes = deriveBatchChartTypes( + resolveDashboardLayout(snapshot.layout), + snapshot.tiles.summaries, + ); + const clientWindow = serverWindow; // threaded as the RSC prop + const clientKey = queryKeys.chartSeriesBatch( + clientTypes.join(","), + clientWindow.from, + clientWindow.to, + ); + + expect(serverKey).toEqual(clientKey); + // Pin the concrete shape too (bodyFat hidden → dropped; resting HR wins). + expect(serverKey).toEqual([ + "chart-data", + "series-batch", + "WEIGHT,BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,RESTING_HEART_RATE,ACTIVITY_STEPS", + "2026-06-15T03:59:59.999Z", + "2026-07-16T03:59:59.999Z", + ]); + }); +}); diff --git a/src/lib/dashboard/batch-chart-types.ts b/src/lib/dashboard/batch-chart-types.ts new file mode 100644 index 000000000..2267a51db --- /dev/null +++ b/src/lib/dashboard/batch-chart-types.ts @@ -0,0 +1,139 @@ +/** + * v1.30.9 — client-safe pure helpers shared by the dashboard's batched + * chart-series read on BOTH sides of the render boundary. + * + * The dashboard fetches every visible non-sleep chart series in ONE batched + * request keyed by `queryKeys.chartSeriesBatch(typesCsv, fromIso, toIso)`. + * The RSC (`src/app/page.tsx`) prefetches that same batch into the dehydrated + * TanStack cache; the client (`src/app/page-client.tsx`) reads it back on + * mount. For the prefetch to land (not silently no-op into a client refetch) + * the server-built key must be byte-identical to the client-built key. + * + * The key has two moving parts — the CSV type list and the ISO window — and + * this module is the SINGLE source of truth for both, so server and client + * cannot drift: + * + * - `deriveBatchChartTypes(layout, summaries)` — both sides call this over the + * SAME snapshot payload (the server dehydrates the very snapshot the client + * reads), so the ordered type list, and therefore the CSV, is identical by + * construction. + * - `computeBatchWindow(now, timezone)` — the server computes the window ONCE + * from the profile timezone and threads it to the client as an RSC prop + * (`batchWindow`). The client adopts the prop verbatim, so no independent + * browser-vs-container computation can disagree by a millisecond or a zone. + * `computeLocalBatchWindow()` is the browser-local fallback for the + * prefetch-off / legacy / fail-soft paths where no prop arrives. + * + * Import-safe from the server RSC and the client component alike: pure, no + * Prisma / db / `node:*` imports (only the client-safe `@/lib/tz/format` + * Intl helpers and the layout type). + */ +import type { DashboardLayout } from "@/lib/dashboard-layout"; +import { + DEFAULT_TIMEZONE, + isValidTimezone, + tzOffsetMinutes, + userDayKey, +} from "@/lib/tz/format"; + +/** + * 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. Hoisted here so page.tsx and + * page-client share ONE constant and the window they build stays in lockstep. + */ +export const BATCH_COVERAGE_DAYS = 31; + +/** Minimal structural view of a tile summary — only the data-floor count. */ +type SummaryCount = { count?: number | null } | null | undefined; + +/** + * Derive the ordered set of measurement types the dashboard batches into one + * series request, from widget CHART visibility (`layout`) AND the per-type + * data floors (`summaries[TYPE].count > 0`). + * + * This is the single implementation of the block that used to live inline in + * `page-client.tsx` — preserving insertion order (weight → BP sys → BP dia → + * pulse-or-resting → bodyFat → steps), the `RESTING_HEART_RATE`-vs-`PULSE` + * pick, and the visibility + count-floor gates. Note the chart gate does NOT + * carry the workouts-module flag the steps *tile* has: this mirrors the + * chart-row logic exactly, not the strip-tile logic. + */ +export function deriveBatchChartTypes( + layout: DashboardLayout, + summaries: Record | undefined, +): string[] { + const isChartVisible = (id: string): boolean => + layout.widgets.find((widget) => widget.id === id)?.visible ?? false; + const count = (type: string): number => summaries?.[type]?.count ?? 0; + + const hasWeight = count("WEIGHT") > 0; + const hasBp = + count("BLOOD_PRESSURE_SYS") > 0 || count("BLOOD_PRESSURE_DIA") > 0; + const hasRestingHr = count("RESTING_HEART_RATE") > 0; + const hasPulse = count("PULSE") > 0 || hasRestingHr; + const hasBodyFat = count("BODY_FAT") > 0; + const hasSteps = count("ACTIVITY_STEPS") > 0; + + const types: string[] = []; + if (isChartVisible("weight") && hasWeight) types.push("WEIGHT"); + if (isChartVisible("bp") && hasBp) { + types.push("BLOOD_PRESSURE_SYS"); + types.push("BLOOD_PRESSURE_DIA"); + } + if (isChartVisible("pulse") && hasPulse) { + types.push(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE"); + } + if (isChartVisible("bodyFat") && hasBodyFat) types.push("BODY_FAT"); + if (isChartVisible("steps") && hasSteps) types.push("ACTIVITY_STEPS"); + return types; +} + +/** ISO window (`from`/`to` instants) bounding the batched series slice. */ +export interface BatchWindow { + from: string; + to: string; +} + +/** + * End-of-day of `now` in `timezone`, as a UTC instant. Falls back to the + * project default zone for an unusable IANA id. Two-step offset refine so the + * rare DST edge (a transition between the as-if-UTC guess and the true local + * instant) still resolves to the right offset. + */ +function endOfLocalDayUtc(now: Date, timezone: string): Date { + const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE; + const dayKey = userDayKey(now, safeTz); // YYYY-MM-DD in the zone + const [year, month, day] = dayKey.split("-").map(Number); + // Wall-clock 23:59:59.999 treated as-if-UTC, then shifted by the zone offset. + const asIfUtc = Date.UTC(year, month - 1, day, 23, 59, 59, 999); + const offset1 = tzOffsetMinutes(new Date(asIfUtc), safeTz); + const corrected = new Date(asIfUtc - offset1 * 60_000); + const offset2 = tzOffsetMinutes(corrected, safeTz); + return new Date(asIfUtc - offset2 * 60_000); +} + +/** + * The batched-series window computed from the PROFILE timezone. End-of-current- + * day in any zone (derived from `now`) is always ≥ `now`, so today's rows are + * always included regardless of the container's UTC clock. Used server-side and + * threaded to the client as the `batchWindow` prop so the key matches exactly. + */ +export function computeBatchWindow(now: Date, timezone: string): BatchWindow { + const to = endOfLocalDayUtc(now, timezone); + const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000); + return { from: from.toISOString(), to: to.toISOString() }; +} + +/** + * Browser-local fallback window — the pre-prefetch client computation, kept + * verbatim for the prefetch-off / legacy / fail-soft paths where no server + * `batchWindow` prop arrives. Local end-of-day, minus `BATCH_COVERAGE_DAYS`. + */ +export function computeLocalBatchWindow(now: Date = new Date()): BatchWindow { + const to = new Date(now); + 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() }; +} diff --git a/src/lib/measurements/series-batch-read.ts b/src/lib/measurements/series-batch-read.ts new file mode 100644 index 000000000..e72ebf04e --- /dev/null +++ b/src/lib/measurements/series-batch-read.ts @@ -0,0 +1,80 @@ +/** + * v1.30.9 — shared core of the batched dashboard daily-series read. + * + * Extracted from `GET /api/measurements/series-batch` so the route AND the + * RSC dashboard prefetch (`src/app/page.tsx`) call ONE code path instead of + * one HTTP-to-self hop. Behaviour is byte-identical to the pre-extraction + * route body: load the source-priority ladder ONCE, fan out per-type through + * the shared `readDailySeries` reader under a `p-limit(4)` cap, and isolate a + * single type's transient DB reject to an empty slice for THAT type only + * (never a 500 that blanks every chart in the row). + */ +import pLimit from "p-limit"; + +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { + readDailySeries, + type DailySeriesRow, +} from "@/lib/measurements/daily-series-read"; +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * Concurrency cap for the per-type rollup reads. Mirrors the W-POOL + * `p-limit(4)` discipline applied across the analytics fan-out so a wide + * dashboard never packs the shared Prisma pool. + */ +export const SERIES_BATCH_CONCURRENCY = 4; + +export interface SeriesBatchResult { + /** Per-type daily series, keyed by `MeasurementType` — the wire shape. */ + series: Record; + /** How many types degraded to an empty slice on a transient reject. */ + degradedTypes: number; +} + +/** + * Read every requested type's rollup-backed daily series for `[from, to]`. + * De-dupes the type list (a caller repeating a type doesn't double-read it) + * while preserving order. + */ +export async function readSeriesBatch( + userId: string, + types: readonly MeasurementType[], + from: Date, + to: Date, +): Promise { + const uniqueTypes = Array.from(new Set(types)) as MeasurementType[]; + + // Load the source-priority ladder ONCE and thread it across every type so + // the batched reader doesn't re-query it per type. + const priorityJson = await loadUserSourcePriority(userId); + + const limit = pLimit(SERIES_BATCH_CONCURRENCY); + let degradedTypes = 0; + const entries = await Promise.all( + uniqueTypes.map((type) => + limit(async (): Promise => { + try { + const rows = await readDailySeries({ + userId, + type, + from, + to, + priorityJson, + }); + return [type, rows] as const; + } catch { + degradedTypes += 1; + return [type, []] as const; + } + }), + ), + ); + + const series: Record = {}; + for (const [type, rows] of entries) { + series[type] = rows; + } + + return { series, degradedTypes }; +} diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index b1fdc9c1d..3fc3e2067 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -65,6 +65,14 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB * - `["user","dashboardWidgets"]` — the resolved widget layout the snapshot * seeds, matched as an exact tuple so the rest of the overloaded `["user", …]` * head (profile, AI provider, insights layout, thresholds) stays off disk. + * + * INVARIANT (bound to the dashboard hero): `["daily", …]` — the Today digest — + * is deliberately NOT here. The hero's data-first render gate in + * `src/app/page-client.tsx` is hydration-safe only while the digest's first- + * render data source is the server dehydration boundary alone, never a client- + * only IndexedDB restore. Adding a `daily` head here would let a restored + * digest paint the hero on the client while the server rendered a skeleton — a + * React #418 text mismatch. Do not add it without revisiting that gate. */ const PERSIST_ALLOWLIST_HEADS = ["dashboard", "chart-data"] as const; diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index bf1ac4035..1fa9ba047 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -270,7 +270,7 @@ function formatIsoWithOffset(date: Date, tz: string): string { * `Intl.DateTimeFormat` does — we reconstruct the offset from the * difference between the wall-clock parts and the UTC parts. */ -function tzOffsetMinutes(date: Date, tz: string): number { +export function tzOffsetMinutes(date: Date, tz: string): number { const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS); const parts = fmt.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): number => From 5c60558d4fe01ea23c9db91d18d78f02dbe6e4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 14:42:18 +0200 Subject: [PATCH 2/3] perf(dashboard): gate the profile-tz batch window on snapshot mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy mode (snapshot flag off) keeps its browser-local batch window unchanged — the profile-tz window + series prefetch run only in snapshot mode where a byte-identical dehydration key exists. --- src/app/page.tsx | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index f06433fdf..883dc9921 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -93,17 +93,23 @@ export default async function DashboardPage() { queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody); queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout); - // Above-the-fold prefetch — the profile-tz window is computed ONCE and - // threaded to the client so the batched-series key matches byte-for-byte. - batchWindow = computeBatchWindow(new Date(), user.timezone); + // Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives + // its chart-type list from analytics queries the RSC did not run, so there + // is no byte-identical key to dehydrate the series under. In legacy mode + // `batchWindow` stays undefined and the client falls back to its own + // browser-local window exactly as before — no behaviour change off-flag. + const snapshotMode = isDashboardSnapshotEnabled(); + + // The profile-tz window is computed ONCE (snapshot mode only) and threaded + // to the client so the batched-series key matches byte-for-byte. + if (snapshotMode) { + batchWindow = computeBatchWindow(new Date(), user.timezone); + } const modules = await resolveModuleMap(user.id); const insightsEnabled = modules.insights !== false; - // Only prefetch the series in snapshot mode: legacy mode derives its - // type list from analytics queries the RSC did not run, so there is no - // byte-identical key to dehydrate under — the client fetches as before. - const seriesTypes = isDashboardSnapshotEnabled() + const seriesTypes = snapshotMode ? deriveBatchChartTypes( resolveDashboardLayout(wireBody.layout), ( @@ -120,7 +126,7 @@ export default async function DashboardPage() { ? loadDailyDigest(user).catch(() => undefined) : Promise.resolve(undefined); const seriesWork = - seriesTypes.length > 0 + batchWindow && seriesTypes.length > 0 ? readSeriesBatch( user.id, seriesTypes as MeasurementType[], @@ -143,7 +149,9 @@ export default async function DashboardPage() { JSON.parse(JSON.stringify(digest)), ); } - if (series !== undefined) { + // `series` is only ever set when `batchWindow` was (snapshot mode) — the + // explicit `batchWindow` guard just narrows the type for the key build. + if (series !== undefined && batchWindow) { queryClient.setQueryData( queryKeys.chartSeriesBatch( seriesTypes.join(","), From f3d92520d15b36503a211acdf8a85eb24c46da4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 14:43:57 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.30.9=20=E2=80=94=20fas?= =?UTF-8?q?ter=20dashboard=20first=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60eab6f09..0b0fdb88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.30.9] — 2026-07-18 + +Faster dashboard first load. + +- **The top card and the charts paint with their data on first load** instead of flashing empty and then filling in. The daily summary and the charts' 31-day series are now read on the server and handed to the page already populated, so there's no wait for a second round of requests after the page appears. The welcome/summary card, likely the slowest thing to show before, now lands with the first paint. + +No migration. No breaking changes. + ## [1.30.8] — 2026-07-18 Hardening for the new heart-rate upload validation. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a9ae1a496..cf15a1967 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.8 + version: 1.30.9 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index eacf041a0..abf5b5906 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.8", + "version": "1.30.9", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 0ebc4d602..472661840 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.8"; + /* @sw-version-fallback */ "v1.30.9"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`