From 02c50acd46e2a75a2393941f229f178f627be9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:55:53 +0200 Subject: [PATCH] feat(insights): unify the derived-metric query hook onto one factory key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parallel loaders for the generic derived-metric route had grown — a components-local hook keyed on a 4-element tuple and a hooks/ hook keyed on a 3-or-4-element tuple. Both shared the ["insights","derived",metric,…] prefix with different tuple shapes, which silently poisons the TanStack cache between surfaces. Collapse to the single components-local hook (auth-gated, 8 s client ceiling, retry-free) on the one insightsDerived factory key; the fourth slot is always present so the metric prefix can no longer carry two shapes. --- .../derived/composite-score-anatomy.tsx | 2 +- .../insights/derived/use-derived-metric.ts | 57 ++++++++++----- src/hooks/use-derived-metric.ts | 72 ------------------- src/lib/query-keys.ts | 29 +++----- 4 files changed, 52 insertions(+), 108 deletions(-) delete mode 100644 src/hooks/use-derived-metric.ts diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 09c9bcadb..b6fb4ac7e 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; -import { useDerivedMetric } from "@/hooks/use-derived-metric"; +import { useDerivedMetric } from "./use-derived-metric"; import { Skeleton } from "@/components/ui/skeleton"; import type { ReadinessValue, diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts index 2230fb180..9d686b9e2 100644 --- a/src/components/insights/derived/use-derived-metric.ts +++ b/src/components/insights/derived/use-derived-metric.ts @@ -1,6 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import type { DerivedCoverage, @@ -9,14 +10,22 @@ import type { } from "@/lib/insights/derived/types"; /** - * v1.10.0 — TanStack Query hook for the generic derived-metric route. + * v1.10.0 — the single TanStack Query hook for the generic derived-metric + * route, shared by every client surface (vitals dashboard tiles, composite + * score-anatomy, the home wellness strip). * * `GET /api/insights/derived?metric=[&type=]` returns the - * compute-once flattened `Derived` the W2b dashboard tiles + per-metric - * detail cards read. The queryKey comes from the centralised factory - * (`queryKeys.insightsDerived`) so the bare-array ESLint rule stays - * satisfied and the cache never poisons across metrics; the read unwraps - * `(await res.json()).data` per the envelope convention. + * compute-once flattened `Derived` the surfaces read. The queryKey comes + * from the centralised factory (`queryKeys.insightsDerived`) so the bare-array + * ESLint rule stays satisfied and the cache never poisons across metrics; the + * read unwraps `(await res.json()).data` per the envelope convention. + * + * Pure compute on the server (sub-second on a warm rollup tenant) — so the + * hook bounds the fetch on an 8 s client ceiling (a slow response means a + * degraded network, not a provider call), keeps a 60 s `staleTime`, gates on + * auth, and never inline-retries. These reads NEVER warm/generate on visit — + * they read already-computed rollup values (the v1.4.36 / v1.9.1 lesson: a + * warm-on-mount fan-out looks like the app hanging). * * Type-only import of the `Derived` facets — no server module enters the * client bundle (the v1.9.0 lesson). The value `T` is left generic so each @@ -24,6 +33,8 @@ import type { * imported as `import type`. */ +const DERIVED_TIMEOUT_MS = 8_000; + /** The flattened wire shape the route emits in the `data` envelope slot. */ export interface DerivedMetricResponse { metric: string; @@ -38,7 +49,7 @@ export interface DerivedMetricResponse { export interface UseDerivedMetricOptions { /** Optional sub-target (the chosen vital for `VITALS_BASELINE`). */ type?: string | null; - /** Gate the fetch (e.g. on auth). Defaults to enabled. */ + /** Gate the fetch (e.g. on a surface flag). Defaults to enabled. */ enabled?: boolean; } @@ -47,21 +58,33 @@ export function useDerivedMetric( options: UseDerivedMetricOptions = {}, ) { const { type = null, enabled = true } = options; + const { isAuthenticated } = useAuth(); + const gated = enabled && isAuthenticated && metric !== ""; + return useQuery({ queryKey: queryKeys.insightsDerived(metric, type), queryFn: async (): Promise> => { - const params = new URLSearchParams({ metric }); - if (type) params.set("type", type); - const res = await fetch(`/api/insights/derived?${params.toString()}`); - if (!res.ok) { - throw new Error(`derived ${metric} request failed (${res.status})`); + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + DERIVED_TIMEOUT_MS, + ); + try { + const params = new URLSearchParams({ metric }); + if (type) params.set("type", type); + const res = await fetch(`/api/insights/derived?${params.toString()}`, { + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`derived ${metric} request failed (${res.status})`); + } + return (await res.json()).data as DerivedMetricResponse; + } finally { + clearTimeout(timeoutHandle); } - return (await res.json()).data as DerivedMetricResponse; }, - enabled, - // Pure compute over the rollup tier — cheap to recompute, but the - // numbers only move on a fresh ingest. A short stale window keeps the - // dashboard snappy without hammering the route on every focus. + enabled: gated, staleTime: 60_000, + retry: 0, }); } diff --git a/src/hooks/use-derived-metric.ts b/src/hooks/use-derived-metric.ts deleted file mode 100644 index 2da6959dd..000000000 --- a/src/hooks/use-derived-metric.ts +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { useAuth } from "@/hooks/use-auth"; -import { queryKeys } from "@/lib/query-keys"; -import type { - DerivedConfidence, - DerivedCoverage, - DerivedProvenance, -} from "@/lib/insights/derived/types"; - -/** - * v1.10.0 — client loader for one derived wellness metric. - * - * Reads the flat `Derived` envelope off `/api/insights/derived?metric=…` - * (the same shape the score-anatomy view + iOS decode). Pure compute on the - * server — sub-second on a warm rollup tenant — so the hook bounds the fetch - * on an 8 s client ceiling (a slow response means a degraded network, not a - * provider call), keeps a 60 s `staleTime`, and never inline-retries. - * - * `T` is the metric-specific value shape (e.g. `ReadinessValue`, - * `SleepScoreValue`); the caller supplies it so the anatomy view stays - * strongly typed without a second cast. - */ - -const DERIVED_TIMEOUT_MS = 8_000; - -/** The flat wire shape the derived route returns inside `{ data }`. */ -export interface DerivedMetricEnvelope { - metric: string; - status: "ok" | "insufficient"; - value: T | null; - coverage: DerivedCoverage; - confidence: DerivedConfidence | null; - provenance: DerivedProvenance; - reason: string | null; -} - -export function useDerivedMetric( - metric: string, - options: { type?: string; enabled?: boolean } = {}, -) { - const { isAuthenticated } = useAuth(); - const enabled = (options.enabled ?? true) && isAuthenticated && metric !== ""; - - return useQuery({ - queryKey: queryKeys.insightsDerivedMetric(metric, options.type), - queryFn: async (): Promise> => { - const controller = new AbortController(); - const timeoutHandle = setTimeout( - () => controller.abort(), - DERIVED_TIMEOUT_MS, - ); - try { - const params = new URLSearchParams({ metric }); - if (options.type) params.set("type", options.type); - const res = await fetch(`/api/insights/derived?${params.toString()}`, { - signal: controller.signal, - }); - if (!res.ok) throw new Error("Failed"); - const json = (await res.json()) as { data: DerivedMetricEnvelope }; - return json.data; - } finally { - clearTimeout(timeoutHandle); - } - }, - enabled, - staleTime: 60 * 1000, - retry: 0, - }); -} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 8504391d0..b6c8af25a 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -144,27 +144,20 @@ export const queryKeys = { /** * v1.10.0 — derived-wellness metrics. One generic route * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals - * dashboard tiles + per-metric detail cards, so the key is - * parameterised by the derived-metric id and the optional sub-type - * (the chosen vital for `VITALS_BASELINE`). Pure compute, locale-free - * (numbers + bands; surfaces localise their own prose), so no locale - * segment — unlike the narrative `*-status` keys above. + * dashboard tiles, the composite score-anatomy view, and the home + * wellness strip — all through the one `useDerivedMetric` hook. + * + * One generic route backs every derived metric (sleep score, readiness, + * coincident-deviation, vitals baseline, …); the optional sub-type is the + * chosen vital for `VITALS_BASELINE`. The fourth tuple slot is always + * present (`null` when no sub-type) so the cache never poisons between a + * 3-element and a 4-element shape for the same metric prefix. Pure compute + * over the rollup tier — a measurement write keeps it fresh by construction + * (next read sees new buckets), so the `["insights"]` prefix carries the + * invalidation fan-out. */ insightsDerived: (metric: string, type?: string | null) => ["insights", "derived", metric, type ?? null] as const, - /** - * v1.10.0 — a derived wellness metric (`/api/insights/derived?metric=…`). - * One generic route backs every derived composite (sleep score, - * readiness, coincident-deviation, vitals baseline, …) so the key is - * parameterised by the metric id alongside the optional sub-type. Pure - * compute over the rollup tier — a measurement write keeps it fresh by - * construction (next read sees new buckets), so the `["insights"]` prefix - * is enough for invalidation fan-out. - */ - insightsDerivedMetric: (metric: string, type?: string) => - type - ? (["insights", "derived", metric, type] as const) - : (["insights", "derived", metric] as const), /** * v1.10.0 — FDR-controlled correlation discovery * (`/api/insights/correlations`). Read-only descriptive surface; the