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,
});
}