Files
HealthLog/src/app/__tests__/dashboard-analytics-split.test.ts
T
Marc-André Bombeck cb9d61a311 perf(dashboard): server-prefetch the snapshot into the first HTML + bundle budget gate
The measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.

src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.

Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.

H4
2026-07-07 12:51:18 +02:00

60 lines
2.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.39.2 — dashboard analytics slim/thick split.
*
* Pre-fix the dashboard fired a single `useAnalyticsQuery()` against the
* thick `/api/analytics` envelope; every per-type tile waited on the
* heavy fan-out before paint. Mood and medication tiles arrived first
* (separate routes) and then every other tile arrived as one burst,
* which the maintainer reported as "etwas nervig" in the v1.4.39.1 post-deploy
* trace.
*
* Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in
* parallel: one for `?slice=summaries` (per-type tile strip) and one
* for the thick envelope (BD-Zielbereich + glucose tiles). The tile
* strip paints from the slim slice as soon as it lands; thick fields
* stream in afterwards. Without this dual-mount the cold-mount UX
* waterfall regresses to the v1.4.39.1 behaviour, so the test pins the
* two call shapes.
*
* v1.7.0 W6 — both calls now carry an `enabled` gate so the unified
* snapshot rollout flag can disable them in favour of a single
* `/api/dashboard/snapshot` cell. With the flag OFF (default) the
* slim/thick split still drives the page, so the protective intent
* holds; the matchers only relaxed to tolerate the `enabled` arg.
*/
const ROOT = join(__dirname, "../../..");
const PAGE_PATH = join(ROOT, "src/app/page-client.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.39.2 — dashboard analytics slim/thick split", () => {
it("mounts both the slim slice and the thick slice in parallel", () => {
const src = load(PAGE_PATH);
// Slim slice — `?slice=summaries` paints the per-type tile strip.
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/);
// Thick slice — the no-slice `useAnalyticsQuery` feeds the
// BD-Zielbereich + glucose tiles. Both must coexist. The thick
// call now opens with `{ enabled:` (no `slice` key).
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*enabled:/);
});
it("merges slim and thick results so call-sites stay shape-stable", () => {
const src = load(PAGE_PATH);
// The merge contract: slim wins on overlapping fields, thick fills
// in `bpInTargetPct*` and `glucoseByContext`. The text anchors
// protect against a future refactor accidentally dropping one of
// the queries — the `data` object that downstream tile rendering
// consumes must stay shape-stable across both query states.
expect(src).toMatch(/analyticsSlimQuery/);
expect(src).toMatch(/analyticsThickQuery/);
expect(src).toMatch(/bpInTargetPct/);
expect(src).toMatch(/glucoseByContext/);
});
});