Files
HealthLog/src/lib/measurements/series-batch-read.ts
T
Marc-André Bombeck 271abd7b64 perf(dashboard): server-prefetch the Today hero + chart series into the dehydrated cache
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.
2026-07-18 14:39:56 +02:00

81 lines
2.6 KiB
TypeScript

/**
* 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<string, DailySeriesRow[]>;
/** 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<SeriesBatchResult> {
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<readonly [MeasurementType, DailySeriesRow[]]> => {
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<string, DailySeriesRow[]> = {};
for (const [type, rows] of entries) {
series[type] = rows;
}
return { series, degradedTypes };
}