mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user