mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Ten routes on `/` refused with `undeclared_mode`, which made the first page of account sharing the only page in it that does not work. Each one now declares a mode, per route rather than as a class. Eight are record reads: the dashboard snapshot, the Today digest, the badge grid, the Coach nudge signal and reminder ledger, the medication low-stock thresholds, and the two presentation blobs. The old objection to admitting an aggregate — a summary can carry a finding from a module the grant does not cover — does not hold while a grant is whole-record and all-or-nothing. Every admission site names that, so the day per-module scope lands the aggregates are findable. The layout routes split their verbs. Reading the owner's arrangement is right and, for the dashboard, forced: the snapshot already carries the layout and the client seeds the same cache cell from it, so an actor answer would put two people's layouts in one key. Writing it is not a caregiver's job, so the PUT and DELETE stay bare and refuse. Same on the medication list, where the stored order names the owner's medication ids and means nothing anywhere else. Two are actor surfaces: the UI language belongs to the person reading the screen and must not be transplanted onto the record, and the feature-flag matrix reads the settings singleton and no user row at all. Also fixes a second instance of the delegate-data-under-owner-banner defect, on the same page: the dashboard's client-side band fallback derived blood- pressure targets and the weight range from `/api/auth/me`, which answers about the caller, and painted them over the record's charts. Under a switch it now derives nothing and waits for the snapshot's own bands. The integration suite drives every admitted route with both accounts seeded, proves the two answers differ, and then demands the owner's back.
191 lines
8.3 KiB
TypeScript
191 lines
8.3 KiB
TypeScript
import {
|
|
dehydrate,
|
|
HydrationBoundary,
|
|
QueryClient,
|
|
} from "@tanstack/react-query";
|
|
|
|
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
|
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 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. 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).
|
|
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
|
* answers null while the browser is acting on somebody else's record. Every
|
|
* read below scopes to `user`, and under a switch `user` is the DELEGATE —
|
|
* so the snapshot, the digest and the widget layout would seed one person's
|
|
* numbers under a banner naming another. Since those three became delegable
|
|
* reads the client's refetch does correct it, which makes the failure a
|
|
* flash of the wrong person's dashboard rather than a permanent one; that
|
|
* is a smaller bug and not a different one, so the accessor still steps
|
|
* aside here. Its docblock carries the full argument, including why
|
|
* resolving the owner here instead is the worse answer.
|
|
* - Fail-soft: no session, an active switch, 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. 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 getUnswitchedSession();
|
|
if (session) {
|
|
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<
|
|
string,
|
|
unknown
|
|
>;
|
|
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 {
|
|
// Prefetch is an accelerator, never a gate — the client path stands.
|
|
}
|
|
|
|
if (dehydratedState === null) {
|
|
return <DashboardPageClient batchWindow={batchWindow} />;
|
|
}
|
|
return (
|
|
<HydrationBoundary state={dehydratedState}>
|
|
<DashboardPageClient batchWindow={batchWindow} />
|
|
</HydrationBoundary>
|
|
);
|
|
}
|