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 },
],
};