feat(insights): surface the four new derived metrics as dashboard tiles

The estimated 6-minute-walk band + the three any-user HealthKit baseline
bands (wrist temperature, stair ascent/descent speed) were registered,
provenance-mapped and engine-tested but rendered on no surface. Add a
"Mobility & body" section to the vitals dashboard: the 6MWT passthrough
re-frame (distance + trend always, percent-of-predicted framing only with
the Enright demographics) and a shared baseline-band tile for the three bands.
Every tile is data-availability-gated like the existing ones — absent with no
readings, CoverageMeter while provisional, value + framing + provenance when
ready — and the section heading hides when none resolve. All four tokens ride
the single batched derived request. Derive the sparkline gradient id via
useId() to avoid duplicate-label collisions.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 20:07:46 +02:00
1 parent a31add266f
commit 17ad93ca5d
9 files changed
+259 -18

No files matched your search

+8 -1
View File
@@ -2550,7 +2550,14 @@
"normal": "Normalbereich (WHO)",
"overweight": "Übergewicht (WHO)",
"obese": "Adipositas (WHO)"
}
},
"sixMinuteBand": {
"green": "{percent}% des für dein Profil vorhergesagten Werts",
"yellow": "{percent}% des für dein Profil vorhergesagten Werts",
"red": "{percent}% des für dein Profil vorhergesagten Werts"
},
"sixMinuteNoBand": "Ergänze Alter, Größe, Gewicht und Geschlecht, um dies einzuordnen",
"mobilitySectionTitle": "Mobilität & Körper"
},
"anatomy": {
"outOf": "/100",
+8 -1
View File
@@ -2550,7 +2550,14 @@
"normal": "Normal range (WHO)",
"overweight": "Overweight (WHO)",
"obese": "Obese (WHO)"
}
},
"sixMinuteBand": {
"green": "{percent}% of predicted for your profile",
"yellow": "{percent}% of predicted for your profile",
"red": "{percent}% of predicted for your profile"
},
"sixMinuteNoBand": "Add your age, height, weight and sex to frame this",
"mobilitySectionTitle": "Mobility & body"
},
"anatomy": {
"outOf": "/100",
+8 -1
View File
@@ -2550,7 +2550,14 @@
"normal": "Rango normal (OMS)",
"overweight": "Sobrepeso (OMS)",
"obese": "Obesidad (OMS)"
}
},
"sixMinuteBand": {
"green": "{percent}% de lo previsto para tu perfil",
"yellow": "{percent}% de lo previsto para tu perfil",
"red": "{percent}% de lo previsto para tu perfil"
},
"sixMinuteNoBand": "Añade tu edad, altura, peso y sexo para encuadrar esto",
"mobilitySectionTitle": "Movilidad y cuerpo"
},
"anatomy": {
"outOf": "/100",
+8 -1
View File
@@ -2550,7 +2550,14 @@
"normal": "Plage normale (OMS)",
"overweight": "Surpoids (OMS)",
"obese": "Obésité (OMS)"
}
},
"sixMinuteBand": {
"green": "{percent}% de la valeur prédite pour votre profil",
"yellow": "{percent}% de la valeur prédite pour votre profil",
"red": "{percent}% de la valeur prédite pour votre profil"
},
"sixMinuteNoBand": "Ajoutez votre âge, taille, poids et sexe pour cadrer ceci",
"mobilitySectionTitle": "Mobilité et corps"
},
"anatomy": {
"outOf": "/100",
+8 -1
View File
@@ -2550,7 +2550,14 @@
"normal": "Intervallo normale (OMS)",
"overweight": "Sovrappeso (OMS)",
"obese": "Obesità (OMS)"
}
},
"sixMinuteBand": {
"green": "{percent}% del valore previsto per il tuo profilo",
"yellow": "{percent}% del valore previsto per il tuo profilo",
"red": "{percent}% del valore previsto per il tuo profilo"
},
"sixMinuteNoBand": "Aggiungi età, altezza, peso e sesso per inquadrare questo dato",
"mobilitySectionTitle": "Mobilità e corpo"
},
"anatomy": {
"outOf": "/100",
+8 -1
View File
@@ -2550,7 +2550,14 @@
"normal": "Zakres prawidłowy (WHO)",
"overweight": "Nadwaga (WHO)",
"obese": "Otyłość (WHO)"
}
},
"sixMinuteBand": {
"green": "{percent}% wartości przewidywanej dla Twojego profilu",
"yellow": "{percent}% wartości przewidywanej dla Twojego profilu",
"red": "{percent}% wartości przewidywanej dla Twojego profilu"
},
"sixMinuteNoBand": "Dodaj wiek, wzrost, wagę i płeć, aby to ująć w kontekście",
"mobilitySectionTitle": "Mobilność i ciało"
},
"anatomy": {
"outOf": "/100",
@@ -173,6 +173,70 @@ describe("<VitalsDashboard>", () => {
expect(html).not.toContain('data-metric="BMI"');
});
it("renders the estimated 6-minute-walk band tile with its percent framing", () => {
mockBatch((token) => {
if (token.metric === "SIX_MINUTE_WALK_BAND") {
return ok({
distanceM: 540,
predictedM: 600,
percentOfPredicted: 90,
band: "green",
trendDelta: 12,
readingCount: 5,
series: [520, 530, 540],
});
}
return insufficient("no_readings_in_window");
});
const html = render(<VitalsDashboard />);
expect(html).toContain('data-slot="vitals-mobility"');
expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"');
expect(html).toContain("90% of predicted");
});
it("renders the 6-minute-walk tile distance-only when demographics are absent", () => {
mockBatch((token) => {
if (token.metric === "SIX_MINUTE_WALK_BAND") {
return ok({
distanceM: 540,
predictedM: null,
percentOfPredicted: null,
band: null,
trendDelta: null,
readingCount: 1,
series: [],
});
}
return insufficient("no_readings_in_window");
});
const html = render(<VitalsDashboard />);
expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"');
// Honest prompt, never a fabricated placement.
expect(html).toContain("Add your age, height, weight and sex");
});
it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => {
mockBatch((token) => {
if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") {
return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] });
}
return insufficient("no_readings_in_window");
});
const html = render(<VitalsDashboard />);
expect(html).toContain('data-slot="vitals-mobility"');
expect(html).toContain('data-metric="STAIR_ASCENT_SPEED_BASELINE"');
expect(html).toContain("Mobility &amp; body");
});
it("hides the whole Mobility & body section when no mobility metric has content", () => {
mockBatch(() => insufficient("no_readings_in_window"));
const html = render(<VitalsDashboard />);
expect(html).not.toContain('data-slot="vitals-mobility"');
expect(html).not.toContain("Mobility &amp; body");
expect(html).not.toContain('data-metric="SIX_MINUTE_WALK_BAND"');
expect(html).not.toContain('data-metric="WRIST_TEMPERATURE_BASELINE"');
});
it("does not read the coincident-deviation flag (now the top-of-overview card)", () => {
// The flag moved to the dedicated `CoincidentDeviationCard`; the dashboard
// batch no longer requests it and the grid never paints it.
@@ -1,5 +1,6 @@
"use client";
import { useId } from "react";
import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
import { cn } from "@/lib/utils";
@@ -80,6 +81,10 @@ export function SparklineDeltaTile({
}: SparklineDeltaTileProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// A stable per-instance id for the gradient <defs>. Deriving it from the
// label slug collides when two tiles share a localized label (the gradient
// fill on the second tile would not resolve); useId() is collision-free.
const fillId = useId();
const arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment);
const trendColor = sentimentColorClass(arrowSentiment);
@@ -111,7 +116,6 @@ export function SparklineDeltaTile({
: arrowSentiment === "negative"
? "var(--warning)"
: "var(--muted-foreground)";
const fillId = `spark-${label.replace(/[^a-zA-Z0-9]/g, "")}`;
return (
<div
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -29,6 +29,7 @@ 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";
import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
/**
@@ -62,6 +63,20 @@ const SECTION_VITALS: string[] = [
"WEIGHT",
];
/**
* v1.10.3 — the any-user HealthKit baseline bands, each pinning one input and
* framing as a personal typical range (median ± k·MAD). The `metric` is the
* batch token + provenance key; `type` drives icon/label/unit. The estimated
* 6-minute-walk re-frame rides the same Mobility section but reads its own
* value shape (`SixMinuteWalkTile`). Most users carry none of these, so the
* tiles simply do not appear (the per-tile absent gate).
*/
const SECTION_MOBILITY: { metric: keyof typeof METRIC_PROVENANCE; type: string }[] = [
{ metric: "STAIR_ASCENT_SPEED_BASELINE", type: "STAIR_ASCENT_SPEED" },
{ metric: "STAIR_DESCENT_SPEED_BASELINE", type: "STAIR_DESCENT_SPEED" },
{ metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" },
];
/** Up-is-bad for the vitals where a rise is unfavourable. */
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
@@ -132,15 +147,31 @@ interface TileProps {
isLoading: boolean;
}
/** A single personal-typical-range tile for one vital. */
/**
* A single median ± k·MAD personal-band tile. Shared by the per-vital
* `VITALS_BASELINE` reads and the v1.10.3 any-user HealthKit bands
* (`WRIST_TEMPERATURE_BASELINE`, `STAIR_ASCENT_SPEED_BASELINE`,
* `STAIR_DESCENT_SPEED_BASELINE`) — every one returns the same
* `VitalsBaselineValue` and frames as a personal typical range. The `metric`
* selects the batch token + provenance entry; `type` drives the icon, label
* and unit (and, for `VITALS_BASELINE`, the read token's `type`).
*/
function BaselineTile({
metric,
type,
read,
isLoading,
}: TileProps & { type: string }) {
}: TileProps & {
metric: keyof typeof METRIC_PROVENANCE;
type: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const data = read<VitalsBaselineValue>({ metric: "VITALS_BASELINE", type });
// `VITALS_BASELINE` is the type-generic engine and needs the type on the
// token; the dedicated bands pin their own single input, so no `type`.
const data = read<VitalsBaselineValue>(
metric === "VITALS_BASELINE" ? { metric, type } : { metric },
);
if (isLoading || !data) return null;
// Absent → don't render the tile at all.
@@ -152,13 +183,17 @@ function BaselineTile({
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
const label = labelKey ? t(labelKey) : type;
const unit = displayUnit(type);
// `VITALS_BASELINE` tiles tag the DOM by their vital type (the stable
// contract the existing surfaces + tests key on); the dedicated bands tag
// by their own metric id.
const tileId = metric === "VITALS_BASELINE" ? type : metric;
// Provisional — building the band; show value-less coverage state.
if (data.status === "insufficient") {
return (
<div
data-slot="vitals-tile"
data-metric={type}
data-metric={tileId}
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"
>
@@ -186,7 +221,7 @@ function BaselineTile({
});
return (
<div data-slot="vitals-tile" data-metric={type} data-state="ok">
<div data-slot="vitals-tile" data-metric={tileId} data-state="ok">
<SparklineDeltaTile
label={label}
value={v.center}
@@ -195,8 +230,44 @@ function BaselineTile({
series={v.series}
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={<MetricProvenance metric={metric} provenance={data.provenance} />}
/>
</div>
);
}
/**
* Estimated 6-minute-walk band tile (`SIX_MINUTE_WALK_BAND` passthrough
* re-frame). Surfaces the device's estimated distance + trend always; the
* percent-of-predicted framing only when the Enright equation's demographics
* are present, otherwise an honest "add your demographics" prompt. Absent
* when no reading exists in the window.
*/
function SixMinuteWalkTile({ read, isLoading }: TileProps) {
const { t } = useTranslations();
const data = read<SixMinuteWalkValue>({ metric: "SIX_MINUTE_WALK_BAND" });
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
const v = data.value;
const framing =
v.band != null && v.percentOfPredicted != null
? t(`insights.derived.vitals.sixMinuteBand.${v.band}`, {
percent: v.percentOfPredicted,
})
: t("insights.derived.vitals.sixMinuteNoBand");
return (
<div data-slot="vitals-tile" data-metric="SIX_MINUTE_WALK_BAND" data-state="ok">
<SparklineDeltaTile
label={t("measurements.typeSixMinuteWalkDistance")}
value={v.distanceM}
unit={getUnitForType("SIX_MINUTE_WALK_DISTANCE")}
icon={MEASUREMENT_TYPE_ICONS.SIX_MINUTE_WALK_DISTANCE ?? Footprints}
series={v.series}
delta={v.trendDelta}
directionSentiment="up-good"
framing={framing}
precision={0}
provenance={
<MetricProvenance metric="VITALS_BASELINE" provenance={data.provenance} />
<MetricProvenance metric="SIX_MINUTE_WALK_BAND" provenance={data.provenance} />
}
/>
</div>
@@ -427,12 +498,37 @@ function hasRenderableVital(read: DerivedBatchRead): boolean {
return false;
}
/**
* Whether the resolved batch holds at least one mobility/body tile worth
* painting. Mirrors the per-tile gates so the Mobility section heading is
* only rendered once there is content under it (no stranded heading over
* blank space). Most users carry none of these → the whole section hides.
*/
function hasRenderableMobility(read: DerivedBatchRead): boolean {
const sixmw = read<SixMinuteWalkValue>({ metric: "SIX_MINUTE_WALK_BAND" });
if (sixmw?.status === "ok" && sixmw.value) return true;
for (const { metric } of SECTION_MOBILITY) {
const band = read<VitalsBaselineValue>({ metric });
if (
band &&
!(band.status === "insufficient" && band.reason === "no_readings_in_window")
) {
return true;
}
}
return false;
}
/**
* The full set of tokens the dashboard reads in one batch — the five
* wellness scores + the four derived re-frames + one baseline per vital
* (minus HRV, which has its own balance tile). The coincident-deviation
* flag is no longer read here: it is now the dedicated "Today's signal"
* card at the top of the overview (`CoincidentDeviationCard`).
* (minus HRV, which has its own balance tile) + the four v1.10.3 mobility/body
* metrics (estimated 6-minute-walk band + the three HealthKit baseline bands).
* The coincident-deviation flag is no longer read here: it is now the
* dedicated "Today's signal" card at the top of the overview
* (`CoincidentDeviationCard`).
*/
function dashboardTokens(): DerivedBatchToken[] {
const tokens: DerivedBatchToken[] = [
@@ -445,11 +541,15 @@ function dashboardTokens(): DerivedBatchToken[] {
{ metric: "VASCULAR_AGE_DELTA" },
{ metric: "HRV_BALANCE" },
{ metric: "BMI" },
{ metric: "SIX_MINUTE_WALK_BAND" },
];
for (const type of SECTION_VITALS) {
if (type === "HEART_RATE_VARIABILITY") continue;
tokens.push({ metric: "VITALS_BASELINE", type });
}
for (const { metric } of SECTION_MOBILITY) {
tokens.push({ metric });
}
return tokens;
}
@@ -514,6 +614,7 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
// vitals the heading would otherwise strand over blank space, then content
// pops in below it — the CLS this pass removes.
const showSection = isLoading || hasRenderableVital(read);
const showMobility = !isLoading && hasRenderableMobility(read);
return (
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
@@ -549,13 +650,43 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
{vitals
.filter((type) => type !== "HEART_RATE_VARIABILITY")
.map((type) => (
<BaselineTile key={type} type={type} {...tileProps} />
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
))}
</>
)}
</div>
</section>
)}
{showMobility && (
<section
data-slot="vitals-mobility"
aria-label={t("insights.derived.vitals.mobilitySectionTitle")}
className="space-y-3"
>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.vitals.mobilitySectionTitle")}
</h2>
<div
data-slot="vitals-mobility-grid"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
>
<SixMinuteWalkTile {...tileProps} />
{SECTION_MOBILITY.map(({ metric, type }) => (
<BaselineTile
key={metric}
metric={metric}
type={type}
{...tileProps}
/>
))}
</div>
</section>
)}
</div>
);
}