diff --git a/messages/de.json b/messages/de.json index 70043c063..f790fc6f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 740befb9c..f9c78cba6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 4faaff79f..4d7b1d04f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index b107feeab..ba70c8a37 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index b5e2fd629..883ac29a3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 789e11015..a1da6cf7a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx index 624406ba1..26969df02 100644 --- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx +++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx @@ -173,6 +173,70 @@ describe("", () => { 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(); + 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(); + 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(); + 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(); + 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. diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index ddc1dada6..1acb2cb8e 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -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 . 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 (
({ 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( + 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 (
@@ -186,7 +221,7 @@ function BaselineTile({ }); return ( -
+
} + /> +
+ ); +} + +/** + * 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({ 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 ( +
+ + } />
@@ -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({ metric: "SIX_MINUTE_WALK_BAND" }); + if (sixmw?.status === "ok" && sixmw.value) return true; + + for (const { metric } of SECTION_MOBILITY) { + const band = read({ 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 (
@@ -549,13 +650,43 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) { {vitals .filter((type) => type !== "HEART_RATE_VARIABILITY") .map((type) => ( - + ))} )}
)} + {showMobility && ( +
+

+ {t("insights.derived.vitals.mobilitySectionTitle")} +

+
+ + {SECTION_MOBILITY.map(({ metric, type }) => ( + + ))} +
+
+ )}
); }