mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
a31add266f
commit
17ad93ca5d
9 files changed
+259
-18
No files matched your search
+8
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user