Files
HealthLog/src/lib/queries/use-dashboard-snapshot.ts
T
Marc-André Bombeck b5e799c2d6 fix(charts): degrade chart failures to one card and heal stale shells per version
The update poller and the chunk-error reload guard key on the target
version instead of disarming for the whole session, so a second deploy
re-arms the self-heal and a stale shell can no longer strand with
404ing chunks. Lazy chart imports retry once and mount inside a
per-card error boundary with a reload affordance — a failed chunk
degrades to one card instead of replacing the page. The JSON API
client attaches a fifteen-second timeout unless the caller brings its
own signal (the raw streaming path is exempt), chart queries surface
errors with a retry instead of rendering as an empty period, per-type
fetch failures reject instead of caching an empty series as fresh, and
the session and snapshot queries retry once on transient network or
server errors.
2026-06-11 21:17:27 +02:00

174 lines
7.3 KiB
TypeScript

"use client";
/**
* v1.7.0 W6 — unified dashboard first-paint snapshot hook.
*
* One client cell against `GET /api/dashboard/snapshot` that hydrates
* every above-the-fold tile, replacing the legacy four-cell waterfall
* (slim analytics + thick analytics + mood + widget layout). Three
* properties matter:
*
* - It is NOT gated on `isAuthenticated`. The server `requireAuth()`
* is the real gate (401 on a missing cookie); on the dashboard
* route the cookie is always present because `src/proxy.ts` has
* already passed the auth / onboarding redirect. Firing un-gated
* removes the `/api/auth/me` round-trip from the cold critical path
* (R-firstpaint §1a — the single biggest first-byte win).
* - `staleTime: 60_000` + `refetchOnMount: false` +
* `refetchOnWindowFocus: false` mirror `DASHBOARD_QUERY_OPTS` so a
* return-to-dashboard within a minute is a free cache hit.
* - `refetchInterval: 120_000` + `refetchIntervalInBackground: false`
* keep an open dashboard live: an idle tab polls the snapshot every
* two minutes so freshly-synced Withings / HealthKit readings appear
* without a manual reload. The poll hits the warm 60 s server cache
* cheaply and only triggers a sub-second rollup rebuild when the
* underlying data actually changed — never the LLM surfaces, which
* stay daily / pre-generated. The interval pauses while the tab is
* backgrounded.
* - The queryKey is the centralised factory entry
* `queryKeys.dashboardSnapshot()`.
*/
import {
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
// Routed through the typed wrapper so a failure carries its HTTP
// status (`ApiError`) for the transient-retry predicate, and the
// default 15 s timeout keeps a stalled response from pinning the
// whole dashboard on its skeleton.
return apiGet<DashboardSnapshot>("/api/dashboard/snapshot");
}
/** Handoff freshness window — mirrors the hook's `staleTime`. */
const PRELOAD_MAX_AGE_MS = 60_000;
/**
* Module-level promise handoff between the preloader and the mounted
* hook. Deliberately OUTSIDE the query cache: writing the response into
* the cache before the dashboard page hydrates (what `prefetchQuery`
* did) makes the page's first client render paint tiles where the
* server HTML painted the skeleton — a React #418 hydration mismatch
* whenever the response beats the page chunk (fast LAN, e2e route
* mocks). The handoff keeps the request in flight while hydration runs
* and lets the mounted cell commit the data itself, post-hydration.
*/
let preloadedSnapshot: {
promise: Promise<DashboardSnapshot>;
startedAt: number;
} | null = null;
export function _resetDashboardSnapshotPreloadForTests(): void {
preloadedSnapshot = null;
}
/**
* Kick the snapshot fetch off ahead of the dashboard page chunk.
*
* v1.16.6 first-load waterfall fix: under a realistic RTT the
* `useDashboardSnapshot` cell only fires once the dashboard page chunk
* has downloaded, parsed, and mounted — measured at ~450 ms after the
* navigation commits (4G / 4x-CPU profile). The route-level preloader in
* `providers.tsx` and the login submit handler call this the moment the
* destination is known, so the request rides in parallel with the chunk
* download instead of behind it. The response parks in the module-level
* handoff (NOT the query cache — see above) until the mounted cell's
* `queryFn` consumes it; preload errors are swallowed by design — the
* mounted cell owns error surfacing with its own fresh attempt.
*/
export function prefetchDashboardSnapshot(queryClient: QueryClient) {
// Expired handoff from a previous visit — drop it so a long-idle slot
// can never serve stale data.
if (
preloadedSnapshot &&
Date.now() - preloadedSnapshot.startedAt >= PRELOAD_MAX_AGE_MS
) {
preloadedSnapshot = null;
}
// A fresh handoff is already in flight / parked.
if (preloadedSnapshot) return;
// Cache already warm (return-to-dashboard within staleTime) — the
// mounted cell serves it without ever calling `queryFn`; fetching here
// would be a wasted request parked in the slot.
const state = queryClient.getQueryState(queryKeys.dashboardSnapshot());
if (
state?.data !== undefined &&
Date.now() - state.dataUpdatedAt < PRELOAD_MAX_AGE_MS
) {
return;
}
const promise = fetchDashboardSnapshot();
promise.catch(() => {
// Swallow + clear so an unconsumed failed preload (racing 401 on
// login, transient network) never becomes an unhandled rejection and
// never gets handed to a later mount.
if (preloadedSnapshot?.promise === promise) preloadedSnapshot = null;
});
preloadedSnapshot = { promise, startedAt: Date.now() };
}
/** Single-use consume of the preload handoff; `null` when absent/expired. */
function takePreloadedSnapshot(): Promise<DashboardSnapshot> | null {
if (!preloadedSnapshot) return null;
const { promise, startedAt } = preloadedSnapshot;
preloadedSnapshot = null;
return Date.now() - startedAt < PRELOAD_MAX_AGE_MS ? promise : null;
}
function makeSnapshotQueryFn(queryClient: QueryClient) {
return async () => {
const preloaded = takePreloadedSnapshot();
let snap: DashboardSnapshot;
if (preloaded) {
try {
snap = await preloaded;
} catch {
// Preload failed — this cell still owns a fresh attempt so a
// transient prefetch error never surfaces as the query error.
snap = await fetchDashboardSnapshot();
}
} else {
snap = await fetchDashboardSnapshot();
}
// Cold-start de-waterfall: the snapshot already carries the
// resolved widget layout, and the per-chart overlay-prefs hook
// (`use-chart-overlay-prefs.ts`) fetches the SAME payload from
// `/api/dashboard/widgets` under `queryKeys.dashboardWidgets()`
// before the chart cells issue their measurement queries. Seeding
// that cache here removes one full request stage from the
// dashboard waterfall (snapshot → widgets → measurements becomes
// snapshot → measurements). Seed ONLY when the slot is empty so a
// later interval refetch never clobbers an optimistic overlay /
// compare-toggle mutation that wrote the key in the meantime.
if (!queryClient.getQueryData(queryKeys.dashboardWidgets())) {
queryClient.setQueryData(queryKeys.dashboardWidgets(), snap.layout);
}
return snap;
};
}
export function useDashboardSnapshot(enabled = true) {
const queryClient = useQueryClient();
return useQuery({
queryKey: queryKeys.dashboardSnapshot(),
queryFn: makeSnapshotQueryFn(queryClient),
enabled,
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
// v1.16.8 — one retry on network errors / 5xx (never 401/403). A
// single transient failure used to flash the full-dashboard empty
// state under the former `retry: false`.
retry: retryOnceOnTransientError,
});
}