mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): add the vitals dashboard + its derived metric tier
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.
This commit is contained in:
1 parent
32bc11f968
commit
663a3ee09d
28 files changed
+1742
-14
No files matched your search
+10
-6
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
@@ -230,6 +246,8 @@ export default function InsightsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<VitalsDashboard enabled={isAuthenticated} />
|
||||
|
||||
{flags.briefing && (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DerivedMetricResponse } from "../use-derived-metric";
|
||||
|
||||
// Drive the grid by stubbing the per-tile query hook; each tile renders the
|
||||
// state the mock returns for its (metric, type) pair.
|
||||
const useDerivedMetric = vi.fn();
|
||||
vi.mock("../use-derived-metric", () => ({
|
||||
useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a),
|
||||
}));
|
||||
|
||||
import { VitalsDashboard } from "../vitals-dashboard";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
type Resp = DerivedMetricResponse<Record<string, unknown>>;
|
||||
|
||||
function ok(value: Record<string, unknown>): { 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("<VitalsDashboard>", () => {
|
||||
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(<VitalsDashboard />);
|
||||
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(<VitalsDashboard />);
|
||||
// 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(<VitalsDashboard />);
|
||||
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(<VitalsDashboard />);
|
||||
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(<VitalsDashboard />);
|
||||
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(<VitalsDashboard />);
|
||||
expect(html).not.toContain('data-metric="BMI"');
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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=<id>[&type=<vital>]` returns the
|
||||
* compute-once flattened `Derived<T>` the W2b dashboard tiles + per-metric
|
||||
* detail cards read. The queryKey comes from the centralised factory
|
||||
* (`queryKeys.insightsDerived`) so the bare-array ESLint rule stays
|
||||
* satisfied and the cache never poisons across metrics; the read unwraps
|
||||
* `(await res.json()).data` per the envelope convention.
|
||||
*
|
||||
* Type-only import of the `Derived<T>` facets — no server module enters the
|
||||
* client bundle (the v1.9.0 lesson). The value `T` is left generic so each
|
||||
* 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<T> {
|
||||
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<T>(
|
||||
metric: string,
|
||||
options: UseDerivedMetricOptions = {},
|
||||
) {
|
||||
const { type = null, enabled = true } = options;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.insightsDerived(metric, type),
|
||||
queryFn: async (): Promise<DerivedMetricResponse<T>> => {
|
||||
const params = new URLSearchParams({ metric });
|
||||
if (type) params.set("type", type);
|
||||
const res = await fetch(`/api/insights/derived?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`derived ${metric} request failed (${res.status})`);
|
||||
}
|
||||
return (await res.json()).data as DerivedMetricResponse<T>;
|
||||
},
|
||||
enabled,
|
||||
// Pure compute over the rollup tier — cheap to recompute, but the
|
||||
// numbers only move on a fresh ingest. A short stale window keeps the
|
||||
// dashboard snappy without hammering the route on every focus.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -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<VitalsBaselineValue>(
|
||||
"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 (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric={type}
|
||||
data-state="provisional"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm" data-slot="vitals-tile-building">
|
||||
{t("insights.derived.vitals.building", {
|
||||
count: data.coverage.historyDays,
|
||||
target: 7,
|
||||
})}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const v = data.value!;
|
||||
const framing = t("insights.derived.vitals.typicalRange", {
|
||||
low: fmt.number(v.low, 1),
|
||||
high: fmt.number(v.high, 1),
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric={type} data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={label}
|
||||
value={v.center}
|
||||
unit={unit}
|
||||
icon={Icon}
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cardio-fitness band tile (VO₂max passthrough re-frame). */
|
||||
function FitnessAgeTile({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<FitnessAgeValue>("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 (
|
||||
<div data-slot="vitals-tile" data-metric="FITNESS_AGE" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeVo2Max")}
|
||||
value={v.vo2Max}
|
||||
unit={getUnitForType("VO2_MAX")}
|
||||
icon={Gauge}
|
||||
delta={v.trendDelta}
|
||||
directionSentiment="up-good"
|
||||
framing={framing}
|
||||
precision={1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vascular-age delta tile (Withings passthrough re-frame). */
|
||||
function VascularAgeTile({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<VascularAgeDeltaValue>(
|
||||
"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 (
|
||||
<div data-slot="vitals-tile" data-metric="VASCULAR_AGE_DELTA" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeVascularAge")}
|
||||
value={v.vascularAge}
|
||||
unit={getUnitForType("VASCULAR_AGE")}
|
||||
icon={HeartPulse}
|
||||
delta={v.trendDelta}
|
||||
directionSentiment="up-bad"
|
||||
framing={framing}
|
||||
precision={0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** HRV (SDNN) balance tile (reuses the baseline engine). */
|
||||
function HrvBalanceTile({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<HrvBalanceValue>("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 (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="HRV_BALANCE"
|
||||
data-state="provisional"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("measurements.typeHeartRateVariability")}
|
||||
</span>
|
||||
<HeartPulse className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.derived.vitals.building", {
|
||||
count: data.coverage.historyDays,
|
||||
target: 7,
|
||||
})}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const v = data.value!;
|
||||
const framing = t(`insights.derived.vitals.hrvBand.${v.band}`);
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric="HRV_BALANCE" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeHeartRateVariability")}
|
||||
value={v.recentAvg}
|
||||
unit={getUnitForType("HEART_RATE_VARIABILITY")}
|
||||
icon={HeartPulse}
|
||||
directionSentiment="up-good"
|
||||
framing={framing}
|
||||
precision={0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** BMI tile (weight + height fallback). */
|
||||
function BmiTile({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<BmiValue>("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 (
|
||||
<div data-slot="vitals-tile" data-metric="BMI" data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeBodyMassIndex")}
|
||||
value={v.bmi}
|
||||
unit={getUnitForType("BODY_MASS_INDEX")}
|
||||
icon={Scale}
|
||||
directionSentiment="neutral"
|
||||
framing={framing}
|
||||
precision={1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
|
||||
const { t } = useTranslations();
|
||||
const vitals = useMemo(() => SECTION_VITALS, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<FitnessAgeTile enabled={enabled} />
|
||||
<VascularAgeTile enabled={enabled} />
|
||||
<HrvBalanceTile enabled={enabled} />
|
||||
<BmiTile enabled={enabled} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} enabled={enabled} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Derived<BmiValue>> {
|
||||
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<BmiValue>({
|
||||
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<BmiValue>({
|
||||
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<BmiValue>({
|
||||
value: {
|
||||
bmi: Math.round(bmi * 10) / 10,
|
||||
category,
|
||||
band,
|
||||
weightKg,
|
||||
heightCm,
|
||||
},
|
||||
coverage,
|
||||
confidence,
|
||||
provenance: {
|
||||
inputs: [WEIGHT_TYPE, "HEIGHT"],
|
||||
source: "live",
|
||||
windowDays,
|
||||
computedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<Derived<unknown>>;
|
||||
}
|
||||
case "FITNESS_AGE":
|
||||
return computeFitnessAge(args.userId, args.profile, {
|
||||
windowDays: args.windowDays,
|
||||
now,
|
||||
}) as Promise<Derived<unknown>>;
|
||||
case "VASCULAR_AGE_DELTA":
|
||||
return computeVascularAgeDelta(args.userId, args.profile, {
|
||||
windowDays: args.windowDays,
|
||||
now,
|
||||
}) as Promise<Derived<unknown>>;
|
||||
case "HRV_BALANCE":
|
||||
return computeHrvBalance(args.userId, args.profile, {
|
||||
windowDays: args.windowDays,
|
||||
now,
|
||||
}) as Promise<Derived<unknown>>;
|
||||
case "BMI":
|
||||
return computeBmi(args.userId, args.profile, {
|
||||
windowDays: args.windowDays,
|
||||
now,
|
||||
}) as Promise<Derived<unknown>>;
|
||||
default:
|
||||
// Registered + implemented but no dispatch arm — treat as a stub.
|
||||
return notImplemented(meta.inputs.map(String), now);
|
||||
|
||||
@@ -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<Derived<FitnessAgeValue>> {
|
||||
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<FitnessAgeValue>({
|
||||
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<FitnessAgeValue>({
|
||||
value: {
|
||||
vo2Max,
|
||||
band,
|
||||
fitnessAgeDeltaYears: deltaYears,
|
||||
referenceBand: reference,
|
||||
trendDelta,
|
||||
readingCount: rows.length,
|
||||
},
|
||||
coverage,
|
||||
confidence,
|
||||
provenance: {
|
||||
inputs: [VO2_MAX_TYPE],
|
||||
source,
|
||||
windowDays,
|
||||
computedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<Derived<HrvBalanceValue>> {
|
||||
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<HrvBalanceValue>({
|
||||
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<HrvBalanceValue>({
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<DerivedMetricId, DerivedMetricMeta> = {
|
||||
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<DerivedMetricId, DerivedMetricMeta> = {
|
||||
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<DerivedMetricId, DerivedMetricMeta> = {
|
||||
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). */
|
||||
|
||||
@@ -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<Derived<VascularAgeDeltaValue>> {
|
||||
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<VascularAgeDeltaValue>({
|
||||
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<VascularAgeDeltaValue>({
|
||||
value: {
|
||||
vascularAge,
|
||||
deltaYears,
|
||||
band,
|
||||
pulseWaveVelocity: pwvRow?.value ?? null,
|
||||
trendDelta,
|
||||
readingCount: vascularRows.length,
|
||||
},
|
||||
coverage,
|
||||
confidence,
|
||||
provenance: {
|
||||
inputs,
|
||||
source,
|
||||
windowDays,
|
||||
computedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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[]])
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user