feat(dashboard): VO2 max trend tile (opt-in, secondary-metric pattern)

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).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 16:04:46 +02:00
1 parent 8f57f5e0d7
commit b324e6c41d
9 files changed
+61

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+36
View File
@@ -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: (
<TrendCard
key="vo2Max"
label={t("dashboard.vo2Max") ?? "VO₂ max"}
latest={vo2Summary?.latest ?? null}
unit={t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)"}
avg7={vo2Summary?.avg7 ?? null}
avg30={vo2Summary?.avg30 ?? null}
slope30={vo2Summary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(vo2Summary)}
icon={Gauge}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(vo2Summary)}
/>
),
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
@@ -48,6 +48,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
// for the polished `<InsightsCardPreview>` (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 }) {
+11
View File
@@ -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 },
],
};