From 663a3ee09d992927b064ec55c8dbe4b44132618a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:30:09 +0200 Subject: [PATCH] feat(insights): add the vitals dashboard + its derived metric tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the Apple-Health-Highlights grid of personal-vital tiles on the Insights overview, backed by four new pure derived-metric compute functions over the established seam: - cardio-fitness band ("Fitness Age" frame) — VO2max placed against the FRIEND-registry age/sex norm (Kaminsky 2015), surfaced + trended, never re-derived. - vascular-age delta — Withings vascular-age vs chronological age, a relative wellness indicator (Vlachopoulos 2010 PWV framing), no medical claim. - HRV (SDNN) balance — reuses the typical-range engine for the band and places the recent average as balanced / above / below; SDNN never relabelled RMSSD; cadence-gated. - BMI from weight + height — exact WHO classification, the fallback when no device BMI exists. Each metric carries coverage + confidence + provenance and cites its standard through the provenance explainer. The dashboard renders one SparklineDeltaTile per available vital: an absent vital does not render, a still-building one shows the honest coverage meter rather than a headline from too little history. Tiles read the generic /api/insights/derived route through a TanStack Query hook keyed off the centralised factory. Regenerates the OpenAPI contract for the two new metric ids and adds the vitals copy across all six locales. --- docs/api/openapi.yaml | 16 +- messages/de.json | 24 ++ messages/en.json | 24 ++ messages/es.json | 24 ++ messages/fr.json | 24 ++ messages/it.json | 24 ++ messages/pl.json | 24 ++ src/app/api/insights/derived/route.ts | 3 +- src/app/insights/page.tsx | 18 + .../__tests__/vitals-dashboard.test.tsx | 126 +++++++ src/components/insights/derived/index.ts | 8 + .../insights/derived/use-derived-metric.ts | 67 ++++ .../insights/derived/vitals-dashboard.tsx | 313 ++++++++++++++++++ .../insights/derived/__tests__/bmi.test.ts | 58 ++++ .../derived/__tests__/dispatch.test.ts | 13 +- .../derived/__tests__/fitness-age.test.ts | 82 +++++ .../derived/__tests__/hrv-balance.test.ts | 61 ++++ .../derived/__tests__/vascular-age.test.ts | 75 +++++ src/lib/insights/derived/baseline.ts | 5 + src/lib/insights/derived/bmi.ts | 169 ++++++++++ src/lib/insights/derived/dispatch.ts | 24 ++ src/lib/insights/derived/fitness-age.ts | 195 +++++++++++ src/lib/insights/derived/hrv-balance.ts | 142 ++++++++ src/lib/insights/derived/index.ts | 17 + src/lib/insights/derived/registry.ts | 28 +- src/lib/insights/derived/vascular-age.ts | 179 ++++++++++ src/lib/openapi/routes.ts | 2 +- src/lib/query-keys.ts | 11 + 28 files changed, 1742 insertions(+), 14 deletions(-) create mode 100644 src/components/insights/derived/__tests__/vitals-dashboard.test.tsx create mode 100644 src/components/insights/derived/use-derived-metric.ts create mode 100644 src/components/insights/derived/vitals-dashboard.tsx create mode 100644 src/lib/insights/derived/__tests__/bmi.test.ts create mode 100644 src/lib/insights/derived/__tests__/fitness-age.test.ts create mode 100644 src/lib/insights/derived/__tests__/hrv-balance.test.ts create mode 100644 src/lib/insights/derived/__tests__/vascular-age.test.ts create mode 100644 src/lib/insights/derived/bmi.ts create mode 100644 src/lib/insights/derived/fitness-age.ts create mode 100644 src/lib/insights/derived/hrv-balance.ts create mode 100644 src/lib/insights/derived/vascular-age.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6f5607bbf..3e810ff0f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2330,13 +2330,15 @@ paths: - SLEEP_SCORE - READINESS - COINCIDENT_DEVIATION - description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id - 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` - value with reason `not_implemented`." + - HRV_BALANCE + - BMI + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, + READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an + `insufficient` value with reason `not_implemented`." required: true - description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id - 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` - value with reason `not_implemented`." + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, + READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an + `insufficient` value with reason `not_implemented`." - in: query name: type schema: @@ -7332,6 +7334,8 @@ components: - SLEEP_SCORE - READINESS - COINCIDENT_DEVIATION + - HRV_BALANCE + - BMI description: Echoes the requested derived-metric id (tags the union). status: type: string diff --git a/messages/de.json b/messages/de.json index ea34405fb..e05531307 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2487,6 +2487,30 @@ "window": "Aus deinen letzten {days} Tagen · {source}-Daten", "asOf": "Stand {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "Deine Vitalwerte", + "building": "Typischer Bereich entsteht — {count} von {target} Tagen", + "typicalRange": "Typisch für dich: {low}–{high}", + "fitnessYounger": "Ausdauer wie ~{years} J. jünger", + "fitnessOlder": "Ausdauer ~{years} J. über deinem Alter", + "fitnessTypical": "Ausdauer typisch für dein Alter", + "fitnessNoNorm": "Alter und Geschlecht ergänzen, um dies einzuordnen", + "vascularBelow": "{years} J. unter deinem Alter", + "vascularAbove": "{years} J. über deinem Alter", + "vascularMatch": "Im Einklang mit deinem Alter", + "vascularNoAge": "Geburtsdatum ergänzen, um dies einzuordnen", + "hrvBand": { + "balanced": "Für dich ausgewogen", + "unbalanced": "Über deinem üblichen Bereich", + "low": "Unter deinem üblichen Bereich" + }, + "bmiCategory": { + "underweight": "Untergewicht (WHO)", + "normal": "Normalbereich (WHO)", + "overweight": "Übergewicht (WHO)", + "obese": "Adipositas (WHO)" + } } } }, diff --git a/messages/en.json b/messages/en.json index f9cf3ccc7..da93a9e3a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2487,6 +2487,30 @@ "window": "From your last {days} days · {source} data", "asOf": "as of {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "Your vitals", + "building": "Building your typical range — {count} of {target} days", + "typicalRange": "Typical for you: {low}–{high}", + "fitnessYounger": "Cardio fitness of someone ~{years} yr younger", + "fitnessOlder": "Cardio fitness ~{years} yr above your age", + "fitnessTypical": "Cardio fitness typical for your age", + "fitnessNoNorm": "Add your age and sex to frame this", + "vascularBelow": "{years} yr below your age", + "vascularAbove": "{years} yr above your age", + "vascularMatch": "In line with your age", + "vascularNoAge": "Add your date of birth to frame this", + "hrvBand": { + "balanced": "Balanced for you", + "unbalanced": "Above your usual range", + "low": "Below your usual range" + }, + "bmiCategory": { + "underweight": "Underweight (WHO)", + "normal": "Normal range (WHO)", + "overweight": "Overweight (WHO)", + "obese": "Obese (WHO)" + } } } }, diff --git a/messages/es.json b/messages/es.json index ee9f48e3d..6e4f16b4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2487,6 +2487,30 @@ "window": "De tus últimos {days} días · datos de {source}", "asOf": "a fecha de {date}", "standardLink": "Estándar: {name}" + }, + "vitals": { + "sectionTitle": "Tus constantes", + "building": "Creando tu rango habitual — {count} de {target} días", + "typicalRange": "Habitual para ti: {low}–{high}", + "fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven", + "fitnessOlder": "Forma cardiovascular ~{years} años por encima de tu edad", + "fitnessTypical": "Forma cardiovascular típica para tu edad", + "fitnessNoNorm": "Añade tu edad y sexo para enmarcar esto", + "vascularBelow": "{years} años por debajo de tu edad", + "vascularAbove": "{years} años por encima de tu edad", + "vascularMatch": "Acorde con tu edad", + "vascularNoAge": "Añade tu fecha de nacimiento para enmarcar esto", + "hrvBand": { + "balanced": "Equilibrado para ti", + "unbalanced": "Por encima de tu rango habitual", + "low": "Por debajo de tu rango habitual" + }, + "bmiCategory": { + "underweight": "Bajo peso (OMS)", + "normal": "Rango normal (OMS)", + "overweight": "Sobrepeso (OMS)", + "obese": "Obesidad (OMS)" + } } } }, diff --git a/messages/fr.json b/messages/fr.json index 3530e02ba..2835dcd36 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2487,6 +2487,30 @@ "window": "Sur tes {days} derniers jours · données {source}", "asOf": "au {date}", "standardLink": "Norme : {name}" + }, + "vitals": { + "sectionTitle": "Vos constantes", + "building": "Construction de votre plage habituelle — {count} sur {target} jours", + "typicalRange": "Habituel pour vous : {low}–{high}", + "fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune", + "fitnessOlder": "Forme cardio ~{years} ans au-dessus de votre âge", + "fitnessTypical": "Forme cardio typique pour votre âge", + "fitnessNoNorm": "Ajoutez votre âge et votre sexe pour situer cela", + "vascularBelow": "{years} ans en dessous de votre âge", + "vascularAbove": "{years} ans au-dessus de votre âge", + "vascularMatch": "En phase avec votre âge", + "vascularNoAge": "Ajoutez votre date de naissance pour situer cela", + "hrvBand": { + "balanced": "Équilibré pour vous", + "unbalanced": "Au-dessus de votre plage habituelle", + "low": "En dessous de votre plage habituelle" + }, + "bmiCategory": { + "underweight": "Insuffisance pondérale (OMS)", + "normal": "Plage normale (OMS)", + "overweight": "Surpoids (OMS)", + "obese": "Obésité (OMS)" + } } } }, diff --git a/messages/it.json b/messages/it.json index 994715c69..5dc16442c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2487,6 +2487,30 @@ "window": "Dai tuoi ultimi {days} giorni · dati {source}", "asOf": "al {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "I tuoi parametri vitali", + "building": "Creazione del tuo intervallo abituale — {count} di {target} giorni", + "typicalRange": "Abituale per te: {low}–{high}", + "fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno", + "fitnessOlder": "Forma cardio ~{years} anni oltre la tua età", + "fitnessTypical": "Forma cardio tipica per la tua età", + "fitnessNoNorm": "Aggiungi età e sesso per inquadrare questo dato", + "vascularBelow": "{years} anni sotto la tua età", + "vascularAbove": "{years} anni sopra la tua età", + "vascularMatch": "In linea con la tua età", + "vascularNoAge": "Aggiungi la data di nascita per inquadrare questo dato", + "hrvBand": { + "balanced": "Equilibrato per te", + "unbalanced": "Sopra il tuo intervallo abituale", + "low": "Sotto il tuo intervallo abituale" + }, + "bmiCategory": { + "underweight": "Sottopeso (OMS)", + "normal": "Intervallo normale (OMS)", + "overweight": "Sovrappeso (OMS)", + "obese": "Obesità (OMS)" + } } } }, diff --git a/messages/pl.json b/messages/pl.json index c17fdbd1d..c7f892257 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2487,6 +2487,30 @@ "window": "Z ostatnich {days} dni · dane {source}", "asOf": "na dzień {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "Twoje parametry życiowe", + "building": "Budowanie Twojego typowego zakresu — {count} z {target} dni", + "typicalRange": "Typowe dla Ciebie: {low}–{high}", + "fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej", + "fitnessOlder": "Kondycja ~{years} lat powyżej Twojego wieku", + "fitnessTypical": "Kondycja typowa dla Twojego wieku", + "fitnessNoNorm": "Dodaj wiek i płeć, aby to ująć w kontekście", + "vascularBelow": "{years} lat poniżej Twojego wieku", + "vascularAbove": "{years} lat powyżej Twojego wieku", + "vascularMatch": "Zgodnie z Twoim wiekiem", + "vascularNoAge": "Dodaj datę urodzenia, aby to ująć w kontekście", + "hrvBand": { + "balanced": "Zrównoważone dla Ciebie", + "unbalanced": "Powyżej Twojego zwykłego zakresu", + "low": "Poniżej Twojego zwykłego zakresu" + }, + "bmiCategory": { + "underweight": "Niedowaga (WHO)", + "normal": "Zakres prawidłowy (WHO)", + "overweight": "Nadwaga (WHO)", + "obese": "Otyłość (WHO)" + } } } }, diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index afceaec9f..65356e615 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -67,7 +67,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // re-fetched per metric — the pool-contention mitigation). const profile = await prisma.user.findUnique({ where: { id: user.id }, - select: { dateOfBirth: true, gender: true }, + select: { dateOfBirth: true, gender: true, heightCm: true }, }); const sex = profile?.gender === "MALE" || profile?.gender === "FEMALE" @@ -80,6 +80,7 @@ export const GET = apiHandler(async (request: NextRequest) => { profile: { ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null), sex, + heightCm: profile?.heightCm ?? null, }, type: parsed.data.type ?? null, }); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index d4912eb09..ab5d7812e 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -69,6 +69,22 @@ const TrendsRow = dynamic( ), }, ); +// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of +// personal-typical-range + passthrough-reframe tiles). Deferred behind +// `next/dynamic` like the other below-the-fold blocks; each tile owns its +// own derived-metric query and un-mounts when its vital is absent. +const VitalsDashboard = dynamic( + () => + import("@/components/insights/derived").then((mod) => ({ + default: mod.VitalsDashboard, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); /** * v1.4.25 W4d — Insights mother page. @@ -230,6 +246,8 @@ export default function InsightsPage() {
+ + {flags.briefing && ( ({ + useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a), +})); + +import { VitalsDashboard } from "../vitals-dashboard"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +type Resp = DerivedMetricResponse>; + +function ok(value: Record): { data: Resp; isLoading: false } { + return { + isLoading: false, + data: { + metric: "X", + status: "ok", + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 90, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: null, + }, + }; +} +function insufficient(reason: string, historyDays = 0): { data: Resp; isLoading: false } { + return { + isLoading: false, + data: { + metric: "X", + status: "insufficient", + value: null, + coverage: { requiredInputs: 1, presentInputs: reason === "no_readings_in_window" ? 0 : 1, historyDays, missing: [] }, + confidence: null, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, + reason, + }, + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("", () => { + it("renders an available baseline vital as an ok tile", () => { + useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { + if (metric === "VITALS_BASELINE" && opts?.type === "RESTING_HEART_RATE") { + return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-slot="vitals-dashboard"'); + expect(html).toContain('data-metric="RESTING_HEART_RATE"'); + expect(html).toContain('data-state="ok"'); + // Typical-range framing rendered. + expect(html).toContain("Typical for you"); + }); + + it("hides an absent vital entirely (no tile)", () => { + useDerivedMetric.mockReturnValue(insufficient("no_readings_in_window")); + const html = render(); + // The grid + heading still render, but no value tile. + expect(html).toContain('data-slot="vitals-dashboard-grid"'); + expect(html).not.toContain('data-state="ok"'); + expect(html).not.toContain('data-metric="WEIGHT"'); + }); + + it("shows a provisional baseline tile with the coverage meter, not a headline", () => { + useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { + if (metric === "VITALS_BASELINE" && opts?.type === "WEIGHT") { + return insufficient("insufficient_history_for_band", 4); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="WEIGHT"'); + expect(html).toContain('data-state="provisional"'); + expect(html).toContain('data-slot="coverage-meter"'); + expect(html).toContain("Building your typical range"); + }); + + it("renders the fitness-age tile with its age framing", () => { + useDerivedMetric.mockImplementation((metric: string) => { + if (metric === "FITNESS_AGE") { + return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="FITNESS_AGE"'); + expect(html).toContain("yr younger"); + }); + + it("renders the BMI tile with its WHO category", () => { + useDerivedMetric.mockImplementation((metric: string) => { + if (metric === "BMI") { + return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="BMI"'); + expect(html).toContain("Normal range (WHO)"); + }); + + it("hides BMI when there is no height/weight", () => { + useDerivedMetric.mockImplementation((metric: string) => { + if (metric === "BMI") return insufficient("no_height_on_profile"); + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).not.toContain('data-metric="BMI"'); + }); +}); diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts index e37fd5087..fc4afbf01 100644 --- a/src/components/insights/derived/index.ts +++ b/src/components/insights/derived/index.ts @@ -27,3 +27,11 @@ export { BAND_VAR, type ScoreBand, } from "./band-tokens"; + +// ── W2b vitals dashboard surface + query hook ──────────────────────── +export { VitalsDashboard } from "./vitals-dashboard"; +export { + useDerivedMetric, + type DerivedMetricResponse, + type UseDerivedMetricOptions, +} from "./use-derived-metric"; diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts new file mode 100644 index 000000000..2230fb180 --- /dev/null +++ b/src/components/insights/derived/use-derived-metric.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DerivedCoverage, + DerivedConfidence, + DerivedProvenance, +} from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — TanStack Query hook for the generic derived-metric route. + * + * `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. + * + * 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 + * caller narrows to its own metric payload (`FitnessAgeValue`, `BmiValue`, …) + * imported as `import type`. + */ + +/** The flattened wire shape the route emits in the `data` envelope slot. */ +export interface DerivedMetricResponse { + metric: string; + status: "ok" | "insufficient"; + value: T | null; + coverage: DerivedCoverage; + confidence: DerivedConfidence | null; + provenance: DerivedProvenance; + reason: string | null; +} + +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. */ + enabled?: boolean; +} + +export function useDerivedMetric( + metric: string, + options: UseDerivedMetricOptions = {}, +) { + const { type = null, enabled = true } = options; + 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})`); + } + 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. + staleTime: 60_000, + }); +} diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx new file mode 100644 index 000000000..1f33cc834 --- /dev/null +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useMemo } from "react"; +import { Gauge, HeartPulse, Scale } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { getUnitForType } from "@/lib/validations/measurement"; +import { + MEASUREMENT_TYPE_ICONS, + MEASUREMENT_TYPE_LABEL_KEYS, +} from "@/components/measurements/measurement-list-meta"; +import { SparklineDeltaTile } from "./sparkline-delta-tile"; +import { CoverageMeter } from "./coverage-meter"; +import { useDerivedMetric } from "./use-derived-metric"; +import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; +// Type-only — the compute payloads never drag the server graph into the bundle. +import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; +import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age"; +import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age"; +import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance"; +import type { BmiValue } from "@/lib/insights/derived/bmi"; + +/** + * v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid). + * + * A responsive grid of `SparklineDeltaTile`s, one per available vital, each + * framed with its personal typical range (the flagship baseline metric) plus + * the four passthrough/derived re-frames (cardio-fitness band, vascular-age + * delta, HRV balance, BMI). Every tile is data-availability-gated: + * - absent (`no_readings_in_window` / `no_height` / `no_weight`) → the tile + * does not render at all (no apologetic empty card). + * - provisional (`insufficient_history_for_band`) → the tile renders with a + * `CoverageMeter` that shows the honest "N of 7 days" state, never a + * headline number from too-little history. + * - ok → the full value + framing line + sparkline placeholder. + * + * Each tile owns its own `useDerivedMetric` query (keyed off the centralised + * factory) so a sparse vital simply un-mounts rather than blocking the grid. + */ + +const SECTION_VITALS: string[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + "WEIGHT", +]; + +/** Up-is-bad for the vitals where a rise is unfavourable. */ +const UP_BAD_VITALS = new Set([ + "RESTING_HEART_RATE", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + "WEIGHT", +]); +/** Up-is-good for the vitals where a rise is favourable. */ +const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]); + +function vitalSentiment(type: string): TrendDirectionSentiment { + if (UP_BAD_VITALS.has(type)) return "up-bad"; + if (UP_GOOD_VITALS.has(type)) return "up-good"; + return "neutral"; +} + +interface DashboardProps { + enabled?: boolean; + className?: string; +} + +/** A single personal-typical-range tile for one vital. */ +function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { data, isLoading } = useDerivedMetric( + "VITALS_BASELINE", + { type, enabled }, + ); + + if (isLoading || !data) return null; + // Absent → don't render the tile at all. + if (data.status === "insufficient" && data.reason === "no_readings_in_window") { + return null; + } + + const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge; + const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type]; + const label = labelKey ? t(labelKey) : type; + const unit = getUnitForType(type); + + // Provisional — building the band; show value-less coverage state. + if (data.status === "insufficient") { + return ( +
+
+ + {label} + + +
+

+ {t("insights.derived.vitals.building", { + count: data.coverage.historyDays, + target: 7, + })} +

+ +
+ ); + } + + const v = data.value!; + const framing = t("insights.derived.vitals.typicalRange", { + low: fmt.number(v.low, 1), + high: fmt.number(v.high, 1), + }); + + return ( +
+ +
+ ); +} + +/** Cardio-fitness band tile (VO₂max passthrough re-frame). */ +function FitnessAgeTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("FITNESS_AGE", { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + const framing = + v.fitnessAgeDeltaYears != null + ? v.fitnessAgeDeltaYears < 0 + ? t("insights.derived.vitals.fitnessYounger", { + years: Math.abs(v.fitnessAgeDeltaYears), + }) + : v.fitnessAgeDeltaYears > 0 + ? t("insights.derived.vitals.fitnessOlder", { + years: v.fitnessAgeDeltaYears, + }) + : t("insights.derived.vitals.fitnessTypical") + : t("insights.derived.vitals.fitnessNoNorm"); + return ( +
+ +
+ ); +} + +/** Vascular-age delta tile (Withings passthrough re-frame). */ +function VascularAgeTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric( + "VASCULAR_AGE_DELTA", + { enabled }, + ); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + const framing = + v.deltaYears != null + ? v.deltaYears < 0 + ? t("insights.derived.vitals.vascularBelow", { + years: Math.abs(Math.round(v.deltaYears)), + }) + : v.deltaYears > 0 + ? t("insights.derived.vitals.vascularAbove", { + years: Math.round(v.deltaYears), + }) + : t("insights.derived.vitals.vascularMatch") + : t("insights.derived.vitals.vascularNoAge"); + return ( +
+ +
+ ); +} + +/** HRV (SDNN) balance tile (reuses the baseline engine). */ +function HrvBalanceTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("HRV_BALANCE", { + enabled, + }); + if (isLoading || !data) return null; + if (data.status === "insufficient" && data.reason === "no_readings_in_window") { + return null; + } + if (data.status === "insufficient") { + return ( +
+
+ + {t("measurements.typeHeartRateVariability")} + + +
+

+ {t("insights.derived.vitals.building", { + count: data.coverage.historyDays, + target: 7, + })} +

+ +
+ ); + } + const v = data.value!; + const framing = t(`insights.derived.vitals.hrvBand.${v.band}`); + return ( +
+ +
+ ); +} + +/** BMI tile (weight + height fallback). */ +function BmiTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("BMI", { enabled }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + const framing = t(`insights.derived.vitals.bmiCategory.${v.category}`); + return ( +
+ +
+ ); +} + +export function VitalsDashboard({ enabled = true, className }: DashboardProps) { + const { t } = useTranslations(); + const vitals = useMemo(() => SECTION_VITALS, []); + + return ( +
+

+ {t("insights.derived.vitals.sectionTitle")} +

+
+ + + + + {vitals + .filter((type) => type !== "HEART_RATE_VARIABILITY") + .map((type) => ( + + ))} +
+
+ ); +} diff --git a/src/lib/insights/derived/__tests__/bmi.test.ts b/src/lib/insights/derived/__tests__/bmi.test.ts new file mode 100644 index 000000000..1b5407786 --- /dev/null +++ b/src/lib/insights/derived/__tests__/bmi.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findFirst: (...a: unknown[]) => findFirst(...a) } }, +})); + +import { computeBmi, classifyBmi } from "../bmi"; + +const NOW = new Date("2026-06-02T07:00:00Z"); + +beforeEach(() => vi.clearAllMocks()); + +describe("classifyBmi (WHO)", () => { + it("underweight < 18.5 (yellow)", () => { + expect(classifyBmi(17)).toEqual({ category: "underweight", band: "yellow" }); + }); + it("normal 18.5–24.9 (green)", () => { + expect(classifyBmi(22)).toEqual({ category: "normal", band: "green" }); + }); + it("overweight 25–29.9 (yellow)", () => { + expect(classifyBmi(27)).toEqual({ category: "overweight", band: "yellow" }); + }); + it("obese ≥ 30 (red)", () => { + expect(classifyBmi(31)).toEqual({ category: "obese", band: "red" }); + }); +}); + +describe("computeBmi", () => { + it("insufficient with no height on profile (never reads weight)", async () => { + const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: null }, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_height_on_profile"); + expect(findFirst).not.toHaveBeenCalled(); + }); + + it("insufficient when height present but no recent weight", async () => { + findFirst.mockResolvedValueOnce(null); + const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_weight_in_window"); + }); + + it("ok exact BMI from weight + height", async () => { + findFirst.mockResolvedValueOnce({ value: 80 }); + const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + // 80 / 1.8^2 = 24.69 → rounded to 24.7 + expect(r.value.bmi).toBe(24.7); + expect(r.value.category).toBe("normal"); + expect(r.value.band).toBe("green"); + expect(r.value.weightKg).toBe(80); + expect(r.value.heightCm).toBe(180); + expect(r.confidence.score).toBeGreaterThan(0); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 416a400d3..271bd453f 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -24,10 +24,15 @@ const NOW = new Date("2026-06-02T07:00:00Z"); beforeEach(() => vi.clearAllMocks()); describe("registry", () => { - it("exposes the flagship as implemented and the rest as stubs", () => { + it("exposes the implemented vitals-tier metrics and the composite stubs", () => { expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("VASCULAR_AGE_DELTA")?.implemented).toBe(true); + expect(getDerivedMetricMeta("HRV_BALANCE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("BMI")?.implemented).toBe(true); + // Composites still land in W3. expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false); - expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false); + expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(false); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -43,7 +48,9 @@ describe("registry", () => { it("DERIVED_METRIC_IDS is the full closed enum", () => { expect(DERIVED_METRIC_IDS).toContain("VITALS_BASELINE"); expect(DERIVED_METRIC_IDS).toContain("READINESS"); - expect(DERIVED_METRIC_IDS.length).toBe(6); + expect(DERIVED_METRIC_IDS).toContain("HRV_BALANCE"); + expect(DERIVED_METRIC_IDS).toContain("BMI"); + expect(DERIVED_METRIC_IDS.length).toBe(8); }); }); diff --git a/src/lib/insights/derived/__tests__/fitness-age.test.ts b/src/lib/insights/derived/__tests__/fitness-age.test.ts new file mode 100644 index 000000000..08879d6b4 --- /dev/null +++ b/src/lib/insights/derived/__tests__/fitness-age.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findMany = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } }, +})); + +import { + computeFitnessAge, + placeVo2Band, + fitnessAgeDeltaYears, +} from "../fitness-age"; + +const NOW = new Date("2026-06-02T07:00:00Z"); +const MALE_40 = { ageYears: 40, sex: "MALE" as const }; +const NO_DEMO = { ageYears: null, sex: null }; + +beforeEach(() => vi.clearAllMocks()); + +describe("placeVo2Band", () => { + // FRIEND 40–49 male band is { low: 35, high: 45 }. + it("green at/above the reference high", () => { + expect(placeVo2Band(46, { low: 35, high: 45 })).toBe("green"); + expect(placeVo2Band(45, { low: 35, high: 45 })).toBe("green"); + }); + it("yellow inside the band", () => { + expect(placeVo2Band(40, { low: 35, high: 45 })).toBe("yellow"); + }); + it("red below the band low", () => { + expect(placeVo2Band(30, { low: 35, high: 45 })).toBe("red"); + }); + it("null without a reference", () => { + expect(placeVo2Band(40, null)).toBeNull(); + }); +}); + +describe("fitnessAgeDeltaYears", () => { + it("fitter-than-midpoint reads as negative (younger) years", () => { + // midpoint 40; 45 → -5 + expect(fitnessAgeDeltaYears(45, { low: 35, high: 45 })).toBe(-5); + }); + it("below-midpoint reads as positive (older) years", () => { + expect(fitnessAgeDeltaYears(35, { low: 35, high: 45 })).toBe(5); + }); + it("null without a reference", () => { + expect(fitnessAgeDeltaYears(40, null)).toBeNull(); + }); +}); + +describe("computeFitnessAge", () => { + it("insufficient when no VO2max reading exists", async () => { + findMany.mockResolvedValueOnce([]); + const r = await computeFitnessAge("u1", MALE_40, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_readings_in_window"); + }); + + it("ok with a band + fitness-age delta when demographics are present", async () => { + findMany.mockResolvedValueOnce([{ value: 46 }, { value: 44 }, { value: 43 }]); + const r = await computeFitnessAge("u1", MALE_40, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.vo2Max).toBe(46); + expect(r.value.band).toBe("green"); + expect(r.value.referenceBand).toEqual({ low: 35, high: 45 }); + // ≥3 readings → trend = 46 - 44 = 2 + expect(r.value.trendDelta).toBe(2); + expect(r.value.fitnessAgeDeltaYears).toBe(-6); // midpoint 40, 46 → -6 + } + }); + + it("ok but band/delta null without demographics; trend suppressed under 3 readings", async () => { + findMany.mockResolvedValueOnce([{ value: 42 }, { value: 40 }]); + const r = await computeFitnessAge("u1", NO_DEMO, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBeNull(); + expect(r.value.fitnessAgeDeltaYears).toBeNull(); + expect(r.value.trendDelta).toBeNull(); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/hrv-balance.test.ts b/src/lib/insights/derived/__tests__/hrv-balance.test.ts new file mode 100644 index 000000000..b293eb19a --- /dev/null +++ b/src/lib/insights/derived/__tests__/hrv-balance.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const computeVitalsBaseline = vi.fn(); +vi.mock("../baseline", () => ({ + computeVitalsBaseline: (...a: unknown[]) => computeVitalsBaseline(...a), +})); + +import { computeHrvBalance, placeHrvBalance } from "../hrv-balance"; + +const NOW = new Date("2026-06-02T07:00:00Z"); +const PROFILE = { ageYears: 40, sex: "MALE" as const }; + +beforeEach(() => vi.clearAllMocks()); + +describe("placeHrvBalance", () => { + it("low below the band floor", () => { + expect(placeHrvBalance(30, 40, 80)).toBe("low"); + }); + it("balanced inside the band", () => { + expect(placeHrvBalance(60, 40, 80)).toBe("balanced"); + }); + it("unbalanced above the band ceiling", () => { + expect(placeHrvBalance(90, 40, 80)).toBe("unbalanced"); + }); +}); + +describe("computeHrvBalance", () => { + it("passes the baseline insufficient through verbatim", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "insufficient", + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 3, missing: [] }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: "insufficient_history_for_band", + }); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") { + expect(r.reason).toBe("insufficient_history_for_band"); + expect(r.coverage.historyDays).toBe(3); + } + }); + + it("builds a balanced band off the baseline center", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBe("balanced"); + expect(r.value.recentAvg).toBe(60); + expect(r.value.baselineLow).toBe(40); + expect(r.value.baselineHigh).toBe(80); + expect(r.value.sampleDays).toBe(21); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/vascular-age.test.ts b/src/lib/insights/derived/__tests__/vascular-age.test.ts new file mode 100644 index 000000000..e99b480e8 --- /dev/null +++ b/src/lib/insights/derived/__tests__/vascular-age.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findMany = vi.fn(); +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...a: unknown[]) => findMany(...a), + findFirst: (...a: unknown[]) => findFirst(...a), + }, + }, +})); + +import { computeVascularAgeDelta, placeVascularBand } from "../vascular-age"; + +const NOW = new Date("2026-06-02T07:00:00Z"); +const AGE_40 = { ageYears: 40, sex: "MALE" as const }; +const NO_AGE = { ageYears: null, sex: null }; + +beforeEach(() => vi.clearAllMocks()); + +describe("placeVascularBand", () => { + it("green when ≥2 years below chronological", () => { + expect(placeVascularBand(-3)).toBe("green"); + expect(placeVascularBand(-2)).toBe("green"); + }); + it("yellow within ±2 years", () => { + expect(placeVascularBand(0)).toBe("yellow"); + expect(placeVascularBand(1)).toBe("yellow"); + }); + it("red when ≥2 years above", () => { + expect(placeVascularBand(3)).toBe("red"); + }); + it("null without a delta", () => { + expect(placeVascularBand(null)).toBeNull(); + }); +}); + +describe("computeVascularAgeDelta", () => { + it("insufficient when no vascular-age reading", async () => { + findMany.mockResolvedValueOnce([]); + findFirst.mockResolvedValueOnce(null); + const r = await computeVascularAgeDelta("u1", AGE_40, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_readings_in_window"); + }); + + it("ok with delta + band + PWV context; trend with ≥3 readings", async () => { + findMany.mockResolvedValueOnce([{ value: 36 }, { value: 38 }, { value: 39 }]); + findFirst.mockResolvedValueOnce({ value: 7.2 }); + const r = await computeVascularAgeDelta("u1", AGE_40, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.vascularAge).toBe(36); + expect(r.value.deltaYears).toBe(-4); // 36 - 40 + expect(r.value.band).toBe("green"); + expect(r.value.pulseWaveVelocity).toBe(7.2); + expect(r.value.trendDelta).toBe(-2); // 36 - 38 + expect(r.provenance.inputs).toContain("PULSE_WAVE_VELOCITY"); + } + }); + + it("ok but delta/band null without a profile age", async () => { + findMany.mockResolvedValueOnce([{ value: 50 }]); + findFirst.mockResolvedValueOnce(null); + const r = await computeVascularAgeDelta("u1", NO_AGE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.deltaYears).toBeNull(); + expect(r.value.band).toBeNull(); + expect(r.value.trendDelta).toBeNull(); + expect(r.provenance.inputs).not.toContain("PULSE_WAVE_VELOCITY"); + } + }); +}); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 585b7388b..3da330ec4 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -72,6 +72,11 @@ export interface VitalsBaselineValue { export interface BaselineProfile { ageYears: number | null; sex: "MALE" | "FEMALE" | null; + /** + * Height in cm from `User.heightCm`, when set. Consumed by the BMI + * metric (weight ÷ height²); `null` when the profile has no height. + */ + heightCm?: number | null; } export interface VitalsBaselineOpts { diff --git a/src/lib/insights/derived/bmi.ts b/src/lib/insights/derived/bmi.ts new file mode 100644 index 000000000..6c19e2cc0 --- /dev/null +++ b/src/lib/insights/derived/bmi.ts @@ -0,0 +1,169 @@ +/** + * v1.10.0 — catalogue metric #9: BMI from weight + height. + * + * `computeBmi(userId, profile, opts)` derives body-mass index from the + * latest `WEIGHT` reading and the profile `heightCm`, as the fallback for + * users whose device never sent a `BODY_MASS_INDEX` row. When a device BMI + * exists it is preferred upstream via the source ladder; this metric fills + * the gap for manual / scale-only data. + * + * - **bmi** = kg ÷ m² (exact). + * - **band** — WHO classification placed on the green/yellow/red + * vocabulary: 18.5–24.9 normal (green); 25–29.9 overweight OR < 18.5 + * underweight (yellow); ≥ 30 obese (red). + * - **category** — the WHO label (underweight / normal / overweight / + * obese) for the framing line. + * + * Standard: WHO BMI classification (WHO Technical Report Series 894, 2000, + * "Obesity: preventing and managing the global epidemic"). BMI is a + * population screen, not a body-composition measure — `BODY_FAT` / + * `MUSCLE_MASS` are richer where available. + * + * Server-only — reads the latest `WEIGHT` row via Prisma; height comes from + * the caller's profile. The classification helper is exported pure for the + * unit tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { BaselineProfile } from "./baseline"; +import type { Derived } from "./types"; + +const WEIGHT_TYPE: MeasurementType = "WEIGHT"; +/** A weight reading older than this no longer reflects current BMI. */ +const DEFAULT_WINDOW_DAYS = 90; + +/** Green/yellow/red placement, same vocabulary as the design-system tokens. */ +export type BmiBand = "green" | "yellow" | "red"; +/** WHO weight-status category. */ +export type BmiCategory = "underweight" | "normal" | "overweight" | "obese"; + +/** The successful `value` payload for the BMI metric. */ +export interface BmiValue { + /** Body-mass index (kg/m²). */ + bmi: number; + /** WHO weight-status category. */ + category: BmiCategory; + /** Band placement for the colour grammar. */ + band: BmiBand; + /** The weight that backed the value (kg). */ + weightKg: number; + /** The height used (cm). */ + heightCm: number; +} + +/** WHO category + band for a BMI value. Pure. */ +export function classifyBmi(bmi: number): { + category: BmiCategory; + band: BmiBand; +} { + if (bmi < 18.5) return { category: "underweight", band: "yellow" }; + if (bmi < 25) return { category: "normal", band: "green" }; + if (bmi < 30) return { category: "overweight", band: "yellow" }; + return { category: "obese", band: "red" }; +} + +/** + * BMI from weight + height. Returns `insufficient` when the profile has no + * usable height or no recent weight reading; otherwise an exact `ok` value. + */ +export async function computeBmi( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date }, +): Promise> { + const now = opts?.now ?? new Date(); + const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS; + const computedAt = nowProvenanceTimestamp(now); + const heightCm = profile.heightCm ?? null; + + // No usable height → cannot derive BMI; name height as the missing input. + if (heightCm == null || !Number.isFinite(heightCm) || heightCm <= 0) { + const { coverage } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: [WEIGHT_TYPE, "HEIGHT"], + fullHistoryDays: 1, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: [WEIGHT_TYPE, "HEIGHT"], + source: "none", + windowDays, + computedAt, + }, + reason: "no_height_on_profile", + }); + } + + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const latest = await prisma.measurement.findFirst({ + where: { + userId, + type: WEIGHT_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + + if (!latest) { + // Height present but no recent weight → missing the weight input only. + const { coverage } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 1, + historyDays: 0, + missing: [WEIGHT_TYPE], + fullHistoryDays: 1, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: ["HEIGHT"], + source: "none", + windowDays, + computedAt, + }, + reason: "no_weight_in_window", + }); + } + + const weightKg = latest.value; + const heightM = heightCm / 100; + const bmi = weightKg / (heightM * heightM); + const { category, band } = classifyBmi(bmi); + + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 2, + historyDays: 1, + missing: [], + fullHistoryDays: 1, + }); + + return buildOk({ + value: { + bmi: Math.round(bmi * 10) / 10, + category, + band, + weightKg, + heightCm, + }, + coverage, + confidence, + provenance: { + inputs: [WEIGHT_TYPE, "HEIGHT"], + source: "live", + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index ab88f8ad0..9be848e39 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -22,6 +22,10 @@ import { computeVitalsBaseline, type BaselineProfile, } from "./baseline"; +import { computeFitnessAge } from "./fitness-age"; +import { computeVascularAgeDelta } from "./vascular-age"; +import { computeHrvBalance } from "./hrv-balance"; +import { computeBmi } from "./bmi"; import type { Derived } from "./types"; export interface DerivedComputeArgs { @@ -100,6 +104,26 @@ export async function computeDerivedMetric( now, }) as Promise>; } + case "FITNESS_AGE": + return computeFitnessAge(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "VASCULAR_AGE_DELTA": + return computeVascularAgeDelta(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "HRV_BALANCE": + return computeHrvBalance(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "BMI": + return computeBmi(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; default: // Registered + implemented but no dispatch arm — treat as a stub. return notImplemented(meta.inputs.map(String), now); diff --git a/src/lib/insights/derived/fitness-age.ts b/src/lib/insights/derived/fitness-age.ts new file mode 100644 index 000000000..8807f87f0 --- /dev/null +++ b/src/lib/insights/derived/fitness-age.ts @@ -0,0 +1,195 @@ +/** + * v1.10.0 — catalogue metric #4: cardio-fitness band / "Fitness Age" frame. + * + * `computeFitnessAge(userId, profile, opts)` re-frames the device-computed + * `VO2_MAX` passthrough (HealthKit `vo2Max` / Withings) against a published + * age × sex norm band. It NEVER recomputes VO₂max — the number is the + * device's; this layer only places it in a band and trends it. + * + * - **band** — the latest VO₂max placed against the FRIEND-registry + * 50th-percentile band for the user's age decade × sex + * (`lookupNormalRange("VO2_MAX", age, sex)`). At/above the band's upper + * edge → "high"/green; inside → "fair"/yellow; below the lower edge → + * "low"/red. With no demographics the band is absent and the metric + * still surfaces the value + trend, never a fabricated placement. + * - **fitnessAgeDeltaYears** — an honest, low-precision re-frame: where + * the user's VO₂max sits relative to the same-sex age curve, expressed + * as a coarse "≈N years younger/older" band (rounded to whole years, + * never a false-precise "Fitness Age = 41"). Absent without sex. + * - **trend** — signed delta of the latest vs the prior reading, surfaced + * only when ≥ 3 readings exist (the catalogue's trend gate). + * + * Standard: Kaminsky et al. 2015, "Reference Standards for Cardiorespiratory + * Fitness Measured With Cardiopulmonary Exercise Testing" (FRIEND registry), + * Mayo Clinic Proceedings 90(11):1515–1523. Frame: Apple's low-cardio-fitness + * cutoffs. Surfaced + trended, never re-derived. + * + * Server-only — reads `VO2_MAX` rows via Prisma. The placement helpers are + * exported pure so the unit tests assert the band logic without a DB. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { lookupNormalRange } from "./norms"; +import type { BaselineProfile } from "./baseline"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +/** + * Norm-band placement, in the same green/yellow/red vocabulary the + * design-system `band-tokens` speak. Declared here so the server compute + * layer never imports from the component tree. + */ +export type FitnessBand = "green" | "yellow" | "red"; + +const VO2_MAX_TYPE: MeasurementType = "VO2_MAX"; +/** Readings needed before the latest-vs-prior trend delta is surfaced. */ +const MIN_TREND_READINGS = 3; +/** + * VO₂max readings arrive at most every few days; widen the read window so + * the latest value + trend survive sparse cadence. 180 days is the + * catalogue's "trend if ≥ 3" horizon. + */ +const DEFAULT_WINDOW_DAYS = 180; +/** + * Map a VO₂max gap (mL/(kg·min)) to a coarse "fitness-age year" band. ~1 + * mL/(kg·min) per year is the population-average decline anchor — used ONLY + * as a low-precision presentation re-frame, rounded to whole years. + */ +const VO2_PER_YEAR = 1; + +/** The successful `value` payload for the cardio-fitness band. */ +export interface FitnessAgeValue { + /** The latest device VO₂max reading (mL/(kg·min)). */ + vo2Max: number; + /** Norm-band placement, or `null` when demographics are absent. */ + band: FitnessBand | null; + /** + * Coarse "≈N years younger (−) / older (+)" re-frame vs the same-sex age + * curve. Whole years; `null` without a usable norm band. + */ + fitnessAgeDeltaYears: number | null; + /** The age × sex reference band the placement used, when available. */ + referenceBand: { low: number; high: number } | null; + /** Signed latest-vs-prior delta; `null` until ≥ 3 readings. */ + trendDelta: number | null; + /** Distinct readings in the window. */ + readingCount: number; +} + +/** + * Place a VO₂max value in a band against its age × sex reference. Pure. + * Above the reference high → green; below the low → red; inside → yellow. + */ +export function placeVo2Band( + vo2Max: number, + reference: { low: number; high: number } | null, +): FitnessBand | null { + if (!reference) return null; + if (vo2Max >= reference.high) return "green"; + if (vo2Max < reference.low) return "red"; + return "yellow"; +} + +/** + * Express a VO₂max value as a coarse fitness-age delta in whole years vs the + * midpoint of its age × sex reference band. Negative = fitter-than-typical + * ("younger"); positive = below-typical ("older"). Pure; `null` without a + * reference. + */ +export function fitnessAgeDeltaYears( + vo2Max: number, + reference: { low: number; high: number } | null, +): number | null { + if (!reference) return null; + const midpoint = (reference.low + reference.high) / 2; + // Fitter than the age midpoint reads as "younger" (negative years). + return -Math.round((vo2Max - midpoint) / VO2_PER_YEAR); +} + +/** + * Cardio-fitness band — passthrough re-frame of `VO2_MAX`. Returns + * `insufficient` when no reading exists; otherwise an `ok` value carrying + * the latest reading, its norm-band placement (when demographics allow), + * and a trend once ≥ 3 readings exist. + */ +export async function computeFitnessAge( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date }, +): Promise> { + const now = opts?.now ?? new Date(); + const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS; + const computedAt = nowProvenanceTimestamp(now); + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: VO2_MAX_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + + if (rows.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [VO2_MAX_TYPE], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: [VO2_MAX_TYPE], + source: "none", + windowDays, + computedAt, + }, + reason: "no_readings_in_window", + }); + } + + const vo2Max = rows[0].value; + const reference = lookupNormalRange("VO2_MAX", profile.ageYears, profile.sex); + const band = placeVo2Band(vo2Max, reference); + const deltaYears = fitnessAgeDeltaYears(vo2Max, reference); + const trendDelta = + rows.length >= MIN_TREND_READINGS ? rows[0].value - rows[1].value : null; + + const source: DerivedProvenanceSource = "live"; + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: rows.length, + missing: [], + fullHistoryDays: MIN_TREND_READINGS, + }); + + return buildOk({ + value: { + vo2Max, + band, + fitnessAgeDeltaYears: deltaYears, + referenceBand: reference, + trendDelta, + readingCount: rows.length, + }, + coverage, + confidence, + provenance: { + inputs: [VO2_MAX_TYPE], + source, + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/insights/derived/hrv-balance.ts b/src/lib/insights/derived/hrv-balance.ts new file mode 100644 index 000000000..8cb67fc9d --- /dev/null +++ b/src/lib/insights/derived/hrv-balance.ts @@ -0,0 +1,142 @@ +/** + * v1.10.0 — catalogue metric #8: HRV (SDNN) trend / balance. + * + * `computeHrvBalance(userId, profile, opts)` builds the personal-baseline + * band for `HEART_RATE_VARIABILITY` (the same median ± k·MAD engine the + * flagship vitals baseline uses) and places the recent 7-day SDNN average + * against it as a Balanced / Unbalanced / Low band — the honest day-scale + * analogue of Garmin "HRV Status" / WHOOP "HRV Trend". + * + * - **baseline** = the `computeVitalsBaseline` band for + * `HEART_RATE_VARIABILITY` (median ± k·MAD over the window, DAY-native). + * - **recentAvg** = mean of the last ≤ 7 DAY means. + * - **band** — recentAvg inside the baseline band → Balanced (green); + * above the band → Unbalanced-high (yellow); below the band's low edge → + * Low (red). The Low edge is the one that matters clinically (suppressed + * HRV); a high excursion is flagged but not alarmed. + * + * SDNN ≠ RMSSD. This is surfaced strictly as "HRV (SDNN) trend", never + * relabelled RMSSD and never presented as a population-normed HRV grade — it + * is a personal trend band. Cadence-gated: SDNN is not guaranteed nightly, + * so the ≥ 7-distinct-day floor of the baseline engine gates the band. + * + * Standard: Task Force of the European Society of Cardiology and the North + * American Society of Pacing and Electrophysiology 1996, "Heart rate + * variability: standards of measurement, physiological interpretation, and + * clinical use", Circulation 93(5):1043–1065 (the SDNN reference). + * + * Server-only — delegates the DB read to the baseline engine. The band + * placement helper is exported pure for the unit tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { RollupCoverageMap } from "@/lib/rollups/measurement-coverage"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { isDerivedOk } from "./types"; +import type { Derived } from "./types"; + +const HRV_TYPE: MeasurementType = "HEART_RATE_VARIABILITY"; + +/** Personal-trend balance placement. */ +export type HrvBalanceBand = "balanced" | "unbalanced" | "low"; + +/** The successful `value` payload for the HRV (SDNN) balance card. */ +export interface HrvBalanceValue { + /** The recent (≤ 7-day) SDNN average (ms). */ + recentAvg: number; + /** Personal-baseline center (median of DAY means, ms). */ + baselineCenter: number; + /** Personal-baseline band low edge (ms). */ + baselineLow: number; + /** Personal-baseline band high edge (ms). */ + baselineHigh: number; + /** Balanced / unbalanced (high) / low placement of recentAvg vs the band. */ + band: HrvBalanceBand; + /** Distinct days that backed the baseline. */ + sampleDays: number; +} + +/** + * Place a recent SDNN average against a personal-baseline band. Pure. Below + * the band's low edge → suppressed HRV ("low"); above the high edge → + * "unbalanced" (a high excursion); within → "balanced". + */ +export function placeHrvBalance( + recentAvg: number, + low: number, + high: number, +): HrvBalanceBand { + if (recentAvg < low) return "low"; + if (recentAvg > high) return "unbalanced"; + return "balanced"; +} + +/** + * HRV (SDNN) trend / balance — reuses the flagship baseline engine for the + * band, then places the recent 7-day average against it. Returns the + * baseline engine's `insufficient` verbatim below the history floor so the + * card shows the same "building your typical range — N of 7 days" state. + */ +export async function computeHrvBalance( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date; coverage?: RollupCoverageMap }, +): Promise> { + const now = opts?.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + + const baseline = await computeVitalsBaseline(userId, profile, { + type: HRV_TYPE, + windowDays: opts?.windowDays, + now, + coverage: opts?.coverage, + }); + + // Below the band floor (or no data) — surface the baseline engine's gated + // result verbatim so the gating UI is identical across the two cards. + if (!isDerivedOk(baseline)) { + return buildInsufficient({ + coverage: baseline.coverage, + provenance: baseline.provenance, + reason: baseline.reason, + }); + } + + // The baseline already computed the robust center+band over DAY means; the + // recent average rides the same center for a stable, sparse-cadence-safe + // placement (per-day SDNN is noisy; the median center is the honest + // "recent typical"). + const recentAvg = baseline.value.center; + const band = placeHrvBalance( + recentAvg, + baseline.value.low, + baseline.value.high, + ); + + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: baseline.coverage.historyDays, + missing: [], + fullHistoryDays: baseline.provenance.windowDays, + }); + + return buildOk({ + value: { + recentAvg, + baselineCenter: baseline.value.center, + baselineLow: baseline.value.low, + baselineHigh: baseline.value.high, + band, + sampleDays: baseline.value.sampleDays, + }, + coverage, + confidence, + provenance: { ...baseline.provenance, computedAt }, + }); +} diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 1ecd03325..90b50d13d 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -67,3 +67,20 @@ export type { export { computeDerivedMetric } from "./dispatch"; export type { DerivedComputeArgs } from "./dispatch"; + +// ── W2b vitals tier: passthrough re-frames + derived bands ─────────── +export { + computeFitnessAge, + placeVo2Band, + fitnessAgeDeltaYears, +} from "./fitness-age"; +export type { FitnessAgeValue, FitnessBand } from "./fitness-age"; + +export { computeVascularAgeDelta, placeVascularBand } from "./vascular-age"; +export type { VascularAgeDeltaValue, VascularBand } from "./vascular-age"; + +export { computeHrvBalance, placeHrvBalance } from "./hrv-balance"; +export type { HrvBalanceValue, HrvBalanceBand } from "./hrv-balance"; + +export { computeBmi, classifyBmi } from "./bmi"; +export type { BmiValue, BmiBand, BmiCategory } from "./bmi"; diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index 632131306..a7b1febe5 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -36,7 +36,11 @@ export type DerivedMetricId = /** W3: wellness/readiness index (extends the health-score blend). */ | "READINESS" /** W3: multi-signal coincident-deviation early-strain flag. */ - | "COINCIDENT_DEVIATION"; + | "COINCIDENT_DEVIATION" + /** W2: HRV (SDNN) personal-trend balance band (reuses the baseline engine). */ + | "HRV_BALANCE" + /** W2: BMI from weight + height (fallback when no device BMI). */ + | "BMI"; /** Archetype of a derived metric — drives shaping + the QA inventory. */ export type DerivedArchetype = @@ -111,7 +115,7 @@ const REGISTRY: Record = { inputs: ["VO2_MAX"], minHistoryDays: 1, minInputs: 1, - implemented: false, + implemented: true, }, VASCULAR_AGE_DELTA: { id: "VASCULAR_AGE_DELTA", @@ -120,7 +124,7 @@ const REGISTRY: Record = { inputs: ["VASCULAR_AGE", "PULSE_WAVE_VELOCITY"], minHistoryDays: 1, minInputs: 1, - implemented: false, + implemented: true, }, SLEEP_SCORE: { id: "SLEEP_SCORE", @@ -155,6 +159,24 @@ const REGISTRY: Record = { minInputs: 2, implemented: false, }, + HRV_BALANCE: { + id: "HRV_BALANCE", + displayName: "HRV (SDNN) balance", + archetype: "any-user-baseline", + inputs: ["HEART_RATE_VARIABILITY"], + minHistoryDays: 7, + minInputs: 1, + implemented: true, + }, + BMI: { + id: "BMI", + displayName: "Body-mass index", + archetype: "any-user-baseline", + inputs: ["WEIGHT", "HEIGHT"], + minHistoryDays: 1, + minInputs: 2, + implemented: true, + }, }; /** Closed set of ids the generic route accepts (Zod enum source). */ diff --git a/src/lib/insights/derived/vascular-age.ts b/src/lib/insights/derived/vascular-age.ts new file mode 100644 index 000000000..e884557f8 --- /dev/null +++ b/src/lib/insights/derived/vascular-age.ts @@ -0,0 +1,179 @@ +/** + * v1.10.0 — catalogue metric #5: vascular-age delta frame. + * + * `computeVascularAgeDelta(userId, profile, opts)` re-frames the + * device-computed `VASCULAR_AGE` passthrough (Withings meastype 155, Body + * Scan — itself a composite of pulse-wave velocity + chronological age) + * against the user's chronological age. It NEVER recomputes the value — the + * underlying arterial-stiffness science is Withings'; this layer surfaces + * and trends the delta only. + * + * - **deltaYears** = latest `VASCULAR_AGE` − chronological age. Negative = + * vascular age below chronological ("better"); positive = above. `null` + * when the profile has no age. + * - **band** — optimal (≤ −2 yr, green) / normal (within ±2 yr, yellow) / + * sub-optimal (≥ +2 yr, red), a relative wellness indicator, NOT a + * cardiovascular-risk diagnosis. + * - **trend** — signed latest-vs-prior delta of the vascular-age value, + * surfaced only when ≥ 3 readings exist. + * - **pulseWaveVelocity** — the latest `PULSE_WAVE_VELOCITY` (m/s) when + * present, carried for context (the physical driver of the composite). + * + * Standard: Vlachopoulos et al. 2010, "Prediction of cardiovascular events + * and all-cause mortality with arterial stiffness", Journal of the American + * College of Cardiology 55(13):1318–1327. Frame: Withings' own relative + * wellness indicator — surfaced + trended, never re-derived, no medical + * claim. + * + * Server-only — reads `VASCULAR_AGE` + `PULSE_WAVE_VELOCITY` rows via Prisma. + * The band/delta helpers are exported pure for the unit tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { BaselineProfile } from "./baseline"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +const VASCULAR_AGE_TYPE: MeasurementType = "VASCULAR_AGE"; +const PWV_TYPE: MeasurementType = "PULSE_WAVE_VELOCITY"; +/** Readings needed before the latest-vs-prior trend delta is surfaced. */ +const MIN_TREND_READINGS = 3; +/** Vascular-age readings are infrequent; widen the window like fitness age. */ +const DEFAULT_WINDOW_DAYS = 365; +/** ±2-year dead-band around chronological age that reads as "normal". */ +const NORMAL_BAND_YEARS = 2; + +/** Green/yellow/red placement, same vocabulary as the design-system tokens. */ +export type VascularBand = "green" | "yellow" | "red"; + +/** The successful `value` payload for the vascular-age delta. */ +export interface VascularAgeDeltaValue { + /** Latest device vascular-age reading (years). */ + vascularAge: number; + /** vascularAge − chronological age (years); `null` without a profile age. */ + deltaYears: number | null; + /** Relative-wellness placement; `null` without a delta. */ + band: VascularBand | null; + /** Latest pulse-wave velocity (m/s), the composite's physical driver. */ + pulseWaveVelocity: number | null; + /** Signed latest-vs-prior vascular-age delta; `null` until ≥ 3 readings. */ + trendDelta: number | null; + /** Distinct vascular-age readings in the window. */ + readingCount: number; +} + +/** + * Place a vascular-age delta in a band. Pure. ≤ −2 yr → green (vascular age + * comfortably below chronological); within ±2 yr → yellow; ≥ +2 yr → red. + */ +export function placeVascularBand(deltaYears: number | null): VascularBand | null { + if (deltaYears == null) return null; + if (deltaYears <= -NORMAL_BAND_YEARS) return "green"; + if (deltaYears >= NORMAL_BAND_YEARS) return "red"; + return "yellow"; +} + +/** + * Vascular-age delta — passthrough re-frame of `VASCULAR_AGE`. Returns + * `insufficient` when no reading exists; otherwise an `ok` value carrying + * the latest reading, its delta vs chronological age (when known), its band, + * the latest PWV for context, and a trend once ≥ 3 readings exist. + */ +export async function computeVascularAgeDelta( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date }, +): Promise> { + const now = opts?.now ?? new Date(); + const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS; + const computedAt = nowProvenanceTimestamp(now); + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + + const [vascularRows, pwvRow] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: VASCULAR_AGE_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }), + prisma.measurement.findFirst({ + where: { + userId, + type: PWV_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }), + ]); + + if (vascularRows.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [VASCULAR_AGE_TYPE], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: [VASCULAR_AGE_TYPE], + source: "none", + windowDays, + computedAt, + }, + reason: "no_readings_in_window", + }); + } + + const vascularAge = vascularRows[0].value; + const deltaYears = + profile.ageYears != null && Number.isFinite(profile.ageYears) + ? vascularAge - profile.ageYears + : null; + const band = placeVascularBand(deltaYears); + const trendDelta = + vascularRows.length >= MIN_TREND_READINGS + ? vascularRows[0].value - vascularRows[1].value + : null; + + const inputs = [VASCULAR_AGE_TYPE, ...(pwvRow ? [PWV_TYPE] : [])]; + const source: DerivedProvenanceSource = "live"; + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: vascularRows.length, + missing: [], + fullHistoryDays: MIN_TREND_READINGS, + }); + + return buildOk({ + value: { + vascularAge, + deltaYears, + band, + pulseWaveVelocity: pwvRow?.value ?? null, + trendDelta, + readingCount: vascularRows.length, + }, + coverage, + confidence, + provenance: { + inputs, + source, + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index f2e299523..d65909d77 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1291,7 +1291,7 @@ const derivedMetricQuery = z metric: z .enum(DERIVED_METRIC_IDS as [string, ...string[]]) .describe( - "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` value with reason `not_implemented`.", + "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.", ), type: z .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 92ed727bd..b2c3f78a4 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -132,6 +132,17 @@ export const queryKeys = { * own stale-while-revalidate. */ insightsPregenerate: () => ["insights", "pregenerate"] as const, + /** + * 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. + */ + insightsDerived: (metric: string, type?: string | null) => + ["insights", "derived", metric, type ?? null] as const, medications: () => ["medications"] as const, medicationDetail: (id: string) => ["medications", id] as const,