feat(insights): unify the derived-metric query hook onto one factory key

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 23:13:26 +02:00
1 parent c5ba38cebb
commit 02c50acd46
4 files changed
+52 -108

No files matched your search

@@ -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,
@@ -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=<id>[&type=<vital>]` returns the
* compute-once flattened `Derived<T>` 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<T>` 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<T>` 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<T> {
metric: string;
@@ -38,7 +49,7 @@ export interface DerivedMetricResponse<T> {
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<T>(
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<DerivedMetricResponse<T>> => {
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<T>;
} finally {
clearTimeout(timeoutHandle);
}
return (await res.json()).data as DerivedMetricResponse<T>;
},
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,
});
}
-72
View File
@@ -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<T>` 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<T> {
metric: string;
status: "ok" | "insufficient";
value: T | null;
coverage: DerivedCoverage;
confidence: DerivedConfidence | null;
provenance: DerivedProvenance;
reason: string | null;
}
export function useDerivedMetric<T>(
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<DerivedMetricEnvelope<T>> => {
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<T> };
return json.data;
} finally {
clearTimeout(timeoutHandle);
}
},
enabled,
staleTime: 60 * 1000,
retry: 0,
});
}
+11 -18
View File
@@ -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