mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #533 from MBombeck/release/v1.30.9
release/v1.30.9 — faster dashboard first load
This commit is contained in:
12 files changed
+710
-110
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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<readonly [MeasurementType, DailySeriesRow[]]> => {
|
||||
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<string, DailySeriesRow[]> = {};
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
+76
-30
@@ -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 = (
|
||||
<MedicationComplianceChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
|
||||
// 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 `<ChartSkeleton>` 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<string>();
|
||||
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 ? (
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : !mounted || digestQuery.isLoading ? (
|
||||
<TodayHeroSkeleton />
|
||||
) : digestQuery.isError ? (
|
||||
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
|
||||
) : digestQuery.data ? (
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : null)}
|
||||
|
||||
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
|
||||
|
||||
+126
-25
@@ -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<T>(ms: number): Promise<T | undefined> {
|
||||
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 <DashboardPageClient />;
|
||||
}
|
||||
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,76 @@ 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 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;
|
||||
|
||||
const seriesTypes = snapshotMode
|
||||
? deriveBatchChartTypes(
|
||||
resolveDashboardLayout(wireBody.layout),
|
||||
(
|
||||
wireBody.tiles as {
|
||||
summaries?: Record<string, { count?: number }>;
|
||||
}
|
||||
)?.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 =
|
||||
batchWindow && 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)),
|
||||
);
|
||||
}
|
||||
// `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(","),
|
||||
batchWindow.from,
|
||||
batchWindow.to,
|
||||
),
|
||||
JSON.parse(JSON.stringify(series)),
|
||||
);
|
||||
}
|
||||
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
} catch {
|
||||
@@ -68,11 +169,11 @@ export default async function DashboardPage() {
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <DashboardPageClient />;
|
||||
return <DashboardPageClient batchWindow={batchWindow} />;
|
||||
}
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<DashboardPageClient />
|
||||
<DashboardPageClient batchWindow={batchWindow} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -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<string, boolean>): 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, SummaryCount> | 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() };
|
||||
}
|
||||
@@ -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<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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
Reference in new issue
Block a user