"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; // 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; /** * 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 * `` 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 { 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 { 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, }); }