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:
Marc-André Bombeck committed 2026-06-02 21:17:05 +02:00
1 parent 32bc11f968
commit 663a3ee09d
28 files changed
+1742 -14

No files matched your search

@@ -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"');
});
});
+8
View File
@@ -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>
);
}