Files
HealthLog/src/lib/queries/use-analytics-query.ts
T

155 lines
6.0 KiB
TypeScript

"use client";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import type { DataSummary } from "@/lib/analytics/trends";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
/**
* v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`.
*
* Pre-fix, seven mount sites each declared `useQuery({ queryKey:
* ["analytics"], … })` with inconsistent `staleTime` / `enabled` /
* `refetchOnMount` options. TanStack treats the first-mount wins → the
* earliest consumer's options govern the shared cache cell, but every
* route-change re-mount still spins up a fresh subscription tree and
* any consumer without an `enabled: isAuthenticated` short-circuit
* re-issues the network on a back-forward swing. The audit
* (`.planning/round-v1433-audit-perf.md` §1) ranks this as the top-1
* ROI win.
*
* The shared hook:
* - centralises the queryKey via `queryKeys.analytics(slice)`,
* - sets `staleTime: 60_000` so a route swing within a minute is a
* free cache hit,
* - sets `refetchOnMount: false` + `refetchOnWindowFocus: false` so
* route transitions never trigger a refetch storm,
* - sets `refetchInterval: 120_000` + `refetchIntervalInBackground:
* false` so an open page (dashboard tile-strip, Insights charts)
* picks up freshly-synced Withings / HealthKit readings without a
* manual reload. The two-minute poll hits the warm 60 s server cache
* cheaply and only rebuilds a rollup when the underlying data moved;
* it never touches the LLM surfaces, which stay daily /
* pre-generated. The interval pauses while the tab is backgrounded,
* - defaults to `enabled: isAuthenticated` so the unauthenticated
* surfaces (registration, sign-in) don't spin up a 401-bound
* request,
* - lets the caller opt into the slim `?slice=summaries` server
* branch shipped in IW1 / C1 when the consumer only needs the
* per-type DataSummary headlines (sub-pages, gating helpers,
* onboarding checklist).
*
* The dashboard's mother query keeps the default slice because it also
* reads `bpInTargetPct*` and `glucoseByContext` — those slots stay
* stubbed on the slim path per the IW1 follow-up.
*/
/**
* Discriminator for the server-side slice. `undefined` (the default)
* routes onto the thick payload; `"summaries"` routes onto IW1's
* `?slice=summaries` branch.
*/
export type AnalyticsSlice = "summaries" | undefined;
/**
* The unwrapped `data` field returned by the API. Typed as a wide
* shape so the dashboard's `bpInTargetPct*` / `glucoseByContext`
* consumers can narrow without a runtime cast. The slim slice fills
* the optional thick fields with `null` / empty so call-site reads
* remain undefined-safe.
*/
export interface AnalyticsRawPayload {
summaries: Record<string, DataSummary>;
// Thick-only fields — present on the default slice, stubbed on the
// slim branch. Optional so the type covers both shapes.
bpInTargetPct?: number | null;
bpInTargetPct7d?: number | null;
bpInTargetPct30d?: number | null;
bpInTargetPctAllTime?: number | null;
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
glucoseByContext?: Record<string, unknown>;
/**
* v1.17.0 — server-authoritative glucose clinical panel (TIR / GMI / eA1C /
* CV% headline + the advanced J-index + LBGI/HBGI tier), computed once over
* the trailing-30-day window. Present on the thick default slice; absent on
* the slim `?slice=summaries` branch. The panel renders this verbatim and
* never re-derives a clinical number client-side.
*/
glucoseClinical?: GlucoseClinicalMetrics;
correlations?: unknown;
healthScore?: unknown;
sleepStages?: unknown;
/**
* v1.4.34 IW-B — per-type freshness map. The dashboard's tile strip
* reads `lastSeenByType[type]?.daysAgo` and forwards it to each
* `<TrendCard>` so a stale tile renders an "Letzter Wert vor Xd"
* caption instead of disappearing. Present on both the thick default
* and the slim `?slice=summaries` branch.
*/
lastSeenByType?: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
// Keep the contract open — additive server fields don't need a
// type bump every release.
[key: string]: unknown;
}
export interface UseAnalyticsQueryOptions {
/**
* `"summaries"` — hit IW1's slim slice (2 SQL passes, no
* correlations / health-score / bp-in-target / sleep-stages).
* Omit (or pass `undefined`) for the thick default payload.
*/
slice?: AnalyticsSlice;
/**
* Optional override for the auth gate. Defaults to
* `isAuthenticated`. The Insights mother page can pass extra
* disabled-flag composition without losing the auth gate.
*/
enabled?: boolean;
}
async function fetchAnalytics(
slice: AnalyticsSlice,
): Promise<AnalyticsRawPayload> {
const res =
slice === "summaries"
? await fetch("/api/analytics?slice=summaries")
: await fetch("/api/analytics");
if (!res.ok) {
throw new Error(`Failed to load analytics (${res.status})`);
}
const json = (await res.json()) as { data: AnalyticsRawPayload };
return json.data;
}
/**
* Shared hook. Returns the raw `data` payload typed as
* `AnalyticsRawPayload`. Consumers can narrow at the call site (e.g.
* `const summaries = query.data?.summaries;`).
*/
export function useAnalyticsQuery(
options: UseAnalyticsQueryOptions = {},
): UseQueryResult<AnalyticsRawPayload, Error> {
const { isAuthenticated } = useAuth();
const slice = options.slice;
const enabled =
options.enabled !== undefined ? options.enabled : isAuthenticated;
return useQuery({
queryKey: queryKeys.analytics(slice),
queryFn: () => fetchAnalytics(slice),
enabled,
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
});
}