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,