From b324e6c41d4c7671c790ddb23cda5675638d375d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 16:04:46 +0200 Subject: [PATCH] feat(dashboard): VO2 max trend tile (opt-in, secondary-metric pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the VO2 max strip tile to /, matching the existing secondary-metric convention (sleep / steps / glucose / oxygen saturation are all default-invisible + opt-in via Settings → Dashboard). Wire-up: - src/lib/dashboard-layout.ts: vo2Max appended to DASHBOARD_WIDGET_IDS and DEFAULT_DASHBOARD_LAYOUT.widgets with visible=false / tileVisible=false. The /api/dashboard/widgets Zod enum is derived from the same array so the toggle is wired automatically. - src/app/page.tsx: vo2Summary from data?.summaries?.VO2_MAX (auto-populated by /api/analytics; the route iterates the full measurementTypeEnum.options list, no backend change needed), hasVo2 data-floor gate, showVo2Tile combined gate, and the TrendCard push next to showStepsTile. directionSentiment is up-good (higher VO2 max is better) and the unit string runs through the t() helper so the iOS app session can localise it. - src/components/settings/dashboard-layout-section.tsx: vo2Max label key added to WIDGET_LABEL_KEYS so the Settings → Dashboard toggle row renders the localized label. - messages/{en,de,fr,es,it,pl}.json: dashboard.vo2Max + vo2MaxUnit keys. "VO₂ max" is universal across all six locales (scientific abbreviation, no translation needed); the unit string is consistent SI notation. No chart-row card — that's an explicit v1.5 deferral per the W8d outline §6.4. The tile lights up the moment the user accumulates any VO2 sample (Withings ingest or the upcoming iOS Health passthrough). --- messages/de.json | 2 ++ messages/en.json | 2 ++ messages/es.json | 2 ++ messages/fr.json | 2 ++ messages/it.json | 2 ++ messages/pl.json | 2 ++ src/app/page.tsx | 36 +++++++++++++++++++ .../settings/dashboard-layout-section.tsx | 2 ++ src/lib/dashboard-layout.ts | 11 ++++++ 9 files changed, 61 insertions(+) diff --git a/messages/de.json b/messages/de.json index dec5bf2ab..cb9ed212e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -247,6 +247,8 @@ "bodyFat": "Körperfett", "sleep": "Schlaf", "steps": "Schritte", + "vo2Max": "VO₂ max", + "vo2MaxUnit": "mL/(kg·min)", "bpInTarget": "BD im Zielbereich", "days30Short": "30T", "medications": "Medikamente", diff --git a/messages/en.json b/messages/en.json index e7070d3d4..94cd10b04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -247,6 +247,8 @@ "bodyFat": "Body Fat", "sleep": "Sleep", "steps": "Steps", + "vo2Max": "VO₂ max", + "vo2MaxUnit": "mL/(kg·min)", "bpInTarget": "BP in Target", "days30Short": "30d", "medications": "Medications", diff --git a/messages/es.json b/messages/es.json index f4a1f4cda..aa899bd54 100644 --- a/messages/es.json +++ b/messages/es.json @@ -247,6 +247,8 @@ "bodyFat": "Grasa corporal", "sleep": "Sueño", "steps": "Pasos", + "vo2Max": "VO₂ max", + "vo2MaxUnit": "mL/(kg·min)", "bpInTarget": "BP in Target", "days30Short": "30d", "medications": "Medicamentos", diff --git a/messages/fr.json b/messages/fr.json index 2b8438b5a..c86cb9658 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -247,6 +247,8 @@ "bodyFat": "Masse grasse", "sleep": "Sommeil", "steps": "Pas", + "vo2Max": "VO₂ max", + "vo2MaxUnit": "mL/(kg·min)", "bpInTarget": "BP in Target", "days30Short": "30d", "medications": "Médicaments", diff --git a/messages/it.json b/messages/it.json index 82e9b4438..7d4f5c40f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -247,6 +247,8 @@ "bodyFat": "Massa grassa", "sleep": "Sonno", "steps": "Passi", + "vo2Max": "VO₂ max", + "vo2MaxUnit": "mL/(kg·min)", "bpInTarget": "BP in Target", "days30Short": "30d", "medications": "Farmaci", diff --git a/messages/pl.json b/messages/pl.json index 4f97e0177..7858390b9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -247,6 +247,8 @@ "bodyFat": "Tkanka tłuszczowa", "sleep": "Sen", "steps": "Kroki", + "vo2Max": "VO₂ max", + "vo2MaxUnit": "mL/(kg·min)", "bpInTarget": "BP in Target", "days30Short": "30d", "medications": "Leki", diff --git a/src/app/page.tsx b/src/app/page.tsx index 2b8a0a26b..856996d55 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import { Activity, Droplet, Footprints, + Gauge, Heart, Moon, Percent, @@ -251,6 +252,10 @@ export default function DashboardPage() { const bf = data?.summaries?.BODY_FAT; const sleepSummary = data?.summaries?.SLEEP_DURATION; const stepsSummary = data?.summaries?.ACTIVITY_STEPS; + // v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics + // auto-populates this summary because the route iterates over the + // full measurementTypeEnum.options list; no backend change needed. + const vo2Summary = data?.summaries?.VO2_MAX; const moodSummary = moodData?.summary; // Resolve full dashboard layout — controls visibility + order of every widget @@ -307,6 +312,7 @@ export default function DashboardPage() { const hasMood = (moodSummary?.count ?? 0) > 0; const hasSleep = (sleepSummary?.count ?? 0) > 0; const hasSteps = (stepsSummary?.count ?? 0) > 0; + const hasVo2 = (vo2Summary?.count ?? 0) > 0; const hasBpInTarget = data?.bpInTargetPct != null; // Tile (strip) gates — controlled by the new `tileVisible` flag. @@ -317,6 +323,7 @@ export default function DashboardPage() { const showMoodTile = isTileVisible("mood") && hasMood; const showSleepTile = isTileVisible("sleep") && hasSleep; const showStepsTile = isTileVisible("steps") && hasSteps; + const showVo2Tile = isTileVisible("vo2Max") && hasVo2; const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; // Chart (lower row) gates — controlled by the legacy `visible` flag. @@ -809,6 +816,35 @@ export default function DashboardPage() { ), }); } + // v1.4.25 W8d — VO2 max trend tile. Self-gates on the + // `vo2Max` widget being enabled (Settings → Dashboard) AND + // the analytics summary carrying at least one sample. Higher + // VO2 max is better, so the directionSentiment is up-good and + // an upward 30-day slope renders the green arrow. Unit + // matches the canonical DB unit in + // src/lib/validations/measurement.ts. + if (showVo2Tile) { + trendCards.push({ + id: "vo2Max", + order: widgetOrder("vo2Max"), + node: ( + + ), + }); + } if (showBpInTargetTile) { /* v1.4.22 A2 — feature parity with every other tile. Synthesise a slope from the difference between the 7-day diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 35c297105..849cae60d 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -48,6 +48,8 @@ const WIDGET_LABEL_KEYS: Record = { // for the polished `` (top severity-ordered AI // recommendations + ring confidence meter + "View all" CTA). insightsPreview: "dashboard.insightsPreview", + // v1.4.25 W8d — VO2 max secondary-metric tile (opt-in). + vo2Max: "dashboard.vo2Max", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 927605a31..e4670daf3 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -34,6 +34,12 @@ export const DASHBOARD_WIDGET_IDS = [ // a `visible` (chart-row) surface; tileVisible is forced false because // there's no tile-strip representation for it. "insightsPreview", + // v1.4.25 W8d — VO2 max trend tile (opt-in, secondary-metric pattern). + // Self-gates on having any VO2_MAX sample; default-invisible so + // existing dashboards stay unchanged until the user enables it in + // Settings → Dashboard. Only the tile-strip surface; chart row + // lands alongside the iOS-app body-composition sub-page in v1.5. + "vo2Max", ] as const; export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; @@ -217,6 +223,11 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // so it stays pinned above charts even if the user reorders other // widgets; only the boolean visibility flag is consumed. { id: "insightsPreview", visible: true, tileVisible: false, order: 14 }, + // v1.4.25 W8d — VO2 max trend tile. Default-invisible on both + // surfaces; opt-in via Settings → Dashboard. Only the tile-strip + // representation today (`visible` stays false so the chart-row + // gate never fires; the chart card lands in v1.5). + { id: "vo2Max", visible: false, tileVisible: false, order: 15 }, ], };