diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 498df2d86..80c9b5f03 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -3874,6 +3874,7 @@ components:
- muscle-mass
- visceral-fat
- lean-body-mass
+ - steps
- active-energy
- workouts
- flights-climbed
@@ -9663,6 +9664,7 @@ components:
- muscle-mass
- visceral-fat
- lean-body-mass
+ - steps
- active-energy
- workouts
- flights-climbed
diff --git a/messages/de.json b/messages/de.json
index 17ead6fb8..b6e9eff2d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1914,6 +1914,7 @@
"navRestingHr": "Ruhepuls",
"navOxygenSaturation": "Sauerstoff",
"navBodyTemperature": "Temperatur",
+ "navSteps": "Schritte",
"navActiveEnergy": "Aktive Energie",
"navAriaLabel": "Zu Abschnitt springen",
"tabStrip": {
@@ -1983,6 +1984,15 @@
"cta": "Körpertemperatur erfassen"
}
},
+ "steps": {
+ "title": "Schritte",
+ "description": "Tägliche Schrittzahl — eine Zeile pro Tag, aus Apple Health oder einem verbundenen Aktivitätsgerät.",
+ "chartTitle": "Schritte pro Tag",
+ "emptyState": {
+ "title": "Noch keine Schrittdaten",
+ "description": "Tägliche Schritt-Summen erscheinen hier, sobald Apple Health oder ein Aktivitätsgerät einen Tagesumfang synchronisiert."
+ }
+ },
"activeEnergy": {
"title": "Aktive Energie",
"description": "Tägliche aktive-Energie-Summe in Kilokalorien — eine Zeile pro Tag, aus Apple Health oder Withings-Aktivität.",
diff --git a/messages/en.json b/messages/en.json
index 0472b7588..5caff21fa 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1914,6 +1914,7 @@
"navRestingHr": "Resting HR",
"navOxygenSaturation": "Oxygen",
"navBodyTemperature": "Temperature",
+ "navSteps": "Steps",
"navActiveEnergy": "Active energy",
"navAriaLabel": "Skip to section",
"tabStrip": {
@@ -1983,6 +1984,15 @@
"cta": "Log body temperature"
}
},
+ "steps": {
+ "title": "Steps",
+ "description": "Daily step count — one row per day, sourced from Apple Health or a connected activity device.",
+ "chartTitle": "Steps per day",
+ "emptyState": {
+ "title": "No step data yet",
+ "description": "Daily step totals appear here once Apple Health or an activity device syncs a day's worth of movement."
+ }
+ },
"activeEnergy": {
"title": "Active energy",
"description": "Daily active-energy total in kilocalories — one row per day, sourced from Apple Health or Withings activity.",
diff --git a/messages/es.json b/messages/es.json
index 2fce32501..16245afac 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1914,6 +1914,7 @@
"navRestingHr": "FC reposo",
"navOxygenSaturation": "Oxígeno",
"navBodyTemperature": "Temperatura",
+ "navSteps": "Pasos",
"navActiveEnergy": "Energía activa",
"navAriaLabel": "Saltar a sección",
"tabStrip": {
@@ -1983,6 +1984,15 @@
"cta": "Registrar temperatura corporal"
}
},
+ "steps": {
+ "title": "Pasos",
+ "description": "Recuento diario de pasos — una fila por día, desde Apple Salud o un dispositivo de actividad conectado.",
+ "chartTitle": "Pasos por día",
+ "emptyState": {
+ "title": "Aún no hay datos de pasos",
+ "description": "Los totales diarios de pasos aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad sincronice un día completo."
+ }
+ },
"activeEnergy": {
"title": "Energía activa",
"description": "Total diario de energía activa en kilocalorías — una fila por día, desde Apple Salud o la actividad de Withings.",
diff --git a/messages/fr.json b/messages/fr.json
index 4e52ce2ed..685bee44f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1914,6 +1914,7 @@
"navRestingHr": "FC repos",
"navOxygenSaturation": "Oxygène",
"navBodyTemperature": "Température",
+ "navSteps": "Pas",
"navActiveEnergy": "Énergie active",
"navAriaLabel": "Aller à la section",
"tabStrip": {
@@ -1983,6 +1984,15 @@
"cta": "Enregistrer la température"
}
},
+ "steps": {
+ "title": "Pas",
+ "description": "Nombre de pas quotidien — une ligne par jour, depuis Apple Santé ou un appareil d'activité connecté.",
+ "chartTitle": "Pas par jour",
+ "emptyState": {
+ "title": "Aucune donnée de pas",
+ "description": "Les totaux de pas quotidiens apparaîtront ici dès qu'Apple Santé ou un appareil d'activité synchronise une journée complète."
+ }
+ },
"activeEnergy": {
"title": "Énergie active",
"description": "Total quotidien d'énergie active en kilocalories — une ligne par jour, depuis Apple Santé ou Withings activité.",
diff --git a/messages/it.json b/messages/it.json
index ad4a4b251..e9f5270e6 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1914,6 +1914,7 @@
"navRestingHr": "FC riposo",
"navOxygenSaturation": "Ossigeno",
"navBodyTemperature": "Temperatura",
+ "navSteps": "Passi",
"navActiveEnergy": "Energia attiva",
"navAriaLabel": "Vai alla sezione",
"tabStrip": {
@@ -1983,6 +1984,15 @@
"cta": "Registra temperatura corporea"
}
},
+ "steps": {
+ "title": "Passi",
+ "description": "Conteggio giornaliero dei passi — una riga al giorno, da Apple Salute o da un dispositivo di attività collegato.",
+ "chartTitle": "Passi al giorno",
+ "emptyState": {
+ "title": "Ancora nessun dato sui passi",
+ "description": "I totali giornalieri dei passi appariranno qui non appena Apple Salute o un dispositivo di attività sincronizza un'intera giornata."
+ }
+ },
"activeEnergy": {
"title": "Energia attiva",
"description": "Totale giornaliero di energia attiva in kilocalorie — una riga al giorno, da Apple Salute o dall'attività Withings.",
diff --git a/messages/pl.json b/messages/pl.json
index edcf0b5ba..7166f54e0 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1914,6 +1914,7 @@
"navRestingHr": "Tętno spoczynkowe",
"navOxygenSaturation": "Tlen",
"navBodyTemperature": "Temperatura",
+ "navSteps": "Kroki",
"navActiveEnergy": "Energia czynna",
"navAriaLabel": "Przejdź do sekcji",
"tabStrip": {
@@ -1983,6 +1984,15 @@
"cta": "Zarejestruj temperaturę ciała"
}
},
+ "steps": {
+ "title": "Kroki",
+ "description": "Dzienna liczba kroków — jeden wiersz na dobę, z Apple Zdrowie lub podłączonego urządzenia aktywności.",
+ "chartTitle": "Kroki dziennie",
+ "emptyState": {
+ "title": "Brak danych o krokach",
+ "description": "Dzienne sumy kroków pojawią się tutaj, gdy Apple Zdrowie lub urządzenie aktywności zsynchronizuje pełny dzień."
+ }
+ },
"activeEnergy": {
"title": "Energia czynna",
"description": "Dzienna suma energii czynnej w kilokaloriach — jeden wiersz na dobę, z Apple Zdrowie lub aktywności Withings.",
diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts
index 0d66a6e01..3fb52d8f4 100644
--- a/src/app/api/analytics/route.ts
+++ b/src/app/api/analytics/route.ts
@@ -254,6 +254,10 @@ async function buildAnalyticsResponse(user: AuthedUser) {
* the resulting `last30Days.pct`.
*/
let bpInTargetPctPriorWeek: number | null = null;
+ // All-time prior-week BP pct — feeds the Health-Score delta so its BP
+ // pillar compares the same window on both ends (the tile headline keeps
+ // its 30-day scope; the score reads all-time per its documented contract).
+ let windowsPriorWeekAllTime: number | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
@@ -305,6 +309,11 @@ async function buildAnalyticsResponse(user: AuthedUser) {
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null;
+ // The Health-Score BP pillar reads the all-time window (see the
+ // `bpInTargetPctForScore` derivation below). Capture the prior-week
+ // run's all-time pct too so the week-over-week delta compares the
+ // same window on both ends.
+ windowsPriorWeekAllTime = windowsPriorWeek.allTime?.pct ?? null;
}
// Per-context glucose summaries (canonical mg/dL).
@@ -362,15 +371,30 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// 2-column projection from `measurements` for the ingest-path
// pills. Falls back to the legacy 37-day raw read on partial /
// missing coverage. Path annotate sits on `meta.healthScore.path`.
+ // The Health-Score BP pillar reads the **all-time** in-target rate, not
+ // the BD-Zielbereich tile headline (which v1.4.x deliberately scopes to
+ // the trailing 30 days). Feeding the tile's `bpInTargetPct` (last-30d)
+ // here regressed the pillar to null for any account with BP history but
+ // no readings inside the trailing month — the score then dropped the
+ // 0.30-weight BP pillar entirely and rendered "no rating" despite the
+ // user having BP data. `health-score.ts` documents the field as the
+ // all-time rate, so route the all-time window through; fall back to the
+ // 30-day window only when the all-time window is itself null (it never
+ // is when 30-day has data), so a brand-new account is unaffected.
+ const bpInTargetPctForScore = bpInTargetPctAllTime ?? bpInTargetPct;
+ const bpInTargetPctPriorWeekForScore =
+ windowsPriorWeekAllTime ?? bpInTargetPctPriorWeek;
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
- bpInTargetPct,
+ bpInTargetPct: bpInTargetPctForScore,
// v1.4.38 — feed the prior-week BP pct into the Health-Score
// helper's previous-window snapshot so the week-over-week delta
// reflects BP movement instead of zeroing out of the BP pillar.
// Computed alongside the current windows via a second
- // `computeBpInTargetFastPath` run anchored at `now - 7d`.
- bpInTargetPctPriorWeek,
+ // `computeBpInTargetFastPath` run anchored at `now - 7d`. Uses the
+ // same all-time window as the current snapshot so the delta is
+ // apples-to-apples.
+ bpInTargetPctPriorWeek: bpInTargetPctPriorWeekForScore,
heightCm: user.heightCm ?? null,
now: new Date(),
coverage,
diff --git a/src/app/insights/steps/page.tsx b/src/app/insights/steps/page.tsx
new file mode 100644
index 000000000..9458655b1
--- /dev/null
+++ b/src/app/insights/steps/page.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Footprints } from "lucide-react";
+
+import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
+
+/**
+ * v1.12 — `/insights/steps`.
+ *
+ * Daily step-count sub-page. Steps are stored as `ACTIVITY_STEPS`
+ * (cumulative per day; the server-side daily-stats helper collapses
+ * per-sample iOS posts into a single daily row). The metric already had
+ * a dashboard tile but was never listed in `sub-page-metric.ts`, so it
+ * never surfaced in the Insights tab strip / sub-page nav despite the
+ * user having step data. Reuses the generic HealthKitMetricPage scaffold;
+ * empty-state carries no manual-entry CTA — the series arrives from Apple
+ * Health or Withings sync.
+ */
+export default function InsightsStepsPage() {
+ return (
+ }
+ emptyStateCtaType={null}
+ coachPrefill="I haven't logged any steps yet — what's a reasonable daily step target, and how does walking more help my health?"
+ />
+ );
+}
diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx
index c13f477f1..8323e2595 100644
--- a/src/components/insights/__tests__/insights-tab-strip.test.tsx
+++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx
@@ -177,6 +177,22 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => {
expect(html).not.toContain(">Active energy<");
});
+ // Steps live behind the "Activity" parent pill (the activity cluster).
+ // A regression had Steps absent from the metric registry entirely, so
+ // step data never surfaced the Activity group in Insights nav even
+ // though the dashboard tile read `summaries.ACTIVITY_STEPS`. This pins
+ // that ACTIVITY_STEPS data lights up the Activity parent pill.
+ it("surfaces the Activity parent pill when only ACTIVITY_STEPS has data", () => {
+ const availability: InsightInputs = {
+ summaries: { ACTIVITY_STEPS: fakeSummary(4200) },
+ hasMood: false,
+ hasMedication: false,
+ };
+ const html = render();
+ expect(html).toContain('data-group="activity"');
+ expect(html).toContain(">Activity<");
+ });
+
it("hides the parent pill when no wave-A metric has data", () => {
const availability: InsightInputs = {
summaries: { WEIGHT: fakeSummary(2) },
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index bf59446f2..b92503cff 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -156,6 +156,10 @@ const SUB_PAGE_TABS: Record<
metric: "LEAN_BODY_MASS",
},
// ── activity ──
+ steps: {
+ labelKey: "insights.navSteps",
+ metric: "ACTIVITY_STEPS",
+ },
"active-energy": {
labelKey: "insights.navActiveEnergy",
metric: "ACTIVE_ENERGY_BURNED",
diff --git a/src/lib/insights/__tests__/metric-availability.test.ts b/src/lib/insights/__tests__/metric-availability.test.ts
index 8d83d60ca..a4754b86d 100644
--- a/src/lib/insights/__tests__/metric-availability.test.ts
+++ b/src/lib/insights/__tests__/metric-availability.test.ts
@@ -169,4 +169,26 @@ describe("hasMetricData", () => {
}),
).toBe(false);
});
+
+ // Steps are stored as `ACTIVITY_STEPS`; the Insights tab strip + the
+ // routed sub-page gate on that key. A regression had Steps absent from
+ // the metric registry entirely, so the pill / sub-page never surfaced
+ // despite the dashboard tile already reading `summaries.ACTIVITY_STEPS`.
+ it("returns true for ACTIVITY_STEPS when the summary count is positive", () => {
+ expect(
+ hasMetricData("ACTIVITY_STEPS", {
+ ...emptyInputs,
+ summaries: { ACTIVITY_STEPS: fakeSummary(4200) },
+ }),
+ ).toBe(true);
+ });
+
+ it("returns false for ACTIVITY_STEPS when the summary count is zero", () => {
+ expect(
+ hasMetricData("ACTIVITY_STEPS", {
+ ...emptyInputs,
+ summaries: { ACTIVITY_STEPS: fakeSummary(0) },
+ }),
+ ).toBe(false);
+ });
});
diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts
index 923e35bcf..1899a9107 100644
--- a/src/lib/insights/metric-availability.ts
+++ b/src/lib/insights/metric-availability.ts
@@ -51,6 +51,14 @@ export type InsightMetric =
| "SLEEP_DURATION"
| "VO2_MAX"
| "STEPS"
+ // The analytics `summaries` map is keyed by `MeasurementType`, so the
+ // gating helper reads `summaries[metric].count`. Steps are stored as
+ // `ACTIVITY_STEPS`; the routed sub-page + tab strip pass this key so
+ // the count resolves on the real DB key (mirrors how the active-energy
+ // surface uses `ACTIVE_ENERGY_BURNED` rather than the `ACTIVE_ENERGY`
+ // alias). The legacy `STEPS` / `ACTIVE_ENERGY` aliases stay for the
+ // metric-status registry's separate id vocabulary.
+ | "ACTIVITY_STEPS"
| "ACTIVE_ENERGY"
// v1.4.32 — wave-A HealthKit metrics promoted to first-class sub-pages.
| "HEART_RATE_VARIABILITY"
diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts
index f91bb6b6d..0659d6759 100644
--- a/src/lib/insights/sub-page-metric.ts
+++ b/src/lib/insights/sub-page-metric.ts
@@ -66,6 +66,11 @@ const SUB_PAGE_METRIC = {
"visceral-fat": ["VISCERAL_FAT"],
"lean-body-mass": ["LEAN_BODY_MASS"],
// ── activity ──
+ // v1.12 — Steps gains a routed sub-page. Stored as `ACTIVITY_STEPS`;
+ // the tile already surfaced on the dashboard but the metric was never
+ // listed here, so it never appeared in the Insights tab strip / sub-page
+ // nav despite the user having step data.
+ steps: ["ACTIVITY_STEPS"],
"active-energy": ["ACTIVE_ENERGY_BURNED"],
// v1.4.32 — workouts surface; the page reads `Workout` rows directly
// through `useWorkouts()` rather than the `summaries` map, so the
@@ -161,6 +166,7 @@ export const SUB_PAGE_GROUP: Partial> = {
"visceral-fat": "body",
"lean-body-mass": "body",
// activity / mobility (v1.7.0)
+ steps: "activity",
"flights-climbed": "activity",
"walking-distance": "activity",
"walking-steadiness": "activity",
diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts
index 1ae8b308c..1d324c9eb 100644
--- a/tests/integration/analytics-health-score.test.ts
+++ b/tests/integration/analytics-health-score.test.ts
@@ -190,6 +190,63 @@ describe("GET /api/analytics — Health Score", () => {
expect(hs.components.compliance.value).toBeGreaterThanOrEqual(95);
});
+ it("scores the BP pillar from all-time history even with no readings in the trailing 30 days", async () => {
+ // Regression pin: the BD-Zielbereich tile headline reads the trailing
+ // 30 days, but the Health-Score BP pillar must read the all-time
+ // window. A prior change fed the 30-day value into the score, so an
+ // account whose BP readings predate the trailing month lost the
+ // 0.30-weight BP pillar entirely and rendered "no rating" despite
+ // having BP data. This pins the all-time feed.
+ const prisma = getPrismaClient();
+ const user = await seedSession("hs-bp-old");
+
+ const now = Date.now();
+ const DAY = 24 * 60 * 60 * 1000;
+
+ // 20 paired in-target readings, all ~60–80 days ago (outside the
+ // trailing-30-day window the tile headline uses, inside the all-time
+ // window the score should use).
+ for (let i = 0; i < 20; i++) {
+ const at = new Date(now - (60 + i) * DAY);
+ await prisma.measurement.create({
+ data: {
+ userId: user.id,
+ type: "BLOOD_PRESSURE_SYS",
+ value: 122,
+ unit: "mmHg",
+ measuredAt: at,
+ },
+ });
+ await prisma.measurement.create({
+ data: {
+ userId: user.id,
+ type: "BLOOD_PRESSURE_DIA",
+ value: 78,
+ unit: "mmHg",
+ measuredAt: at,
+ },
+ });
+ }
+
+ const { GET } = await import("@/app/api/analytics/route");
+ const res = await (GET as (req: Request) => Promise)(
+ new Request("http://localhost/api/analytics"),
+ );
+ expect(res.status).toBe(200);
+ const env = (await res.json()) as AnalyticsEnvelope & {
+ data: { bpInTargetPct: number | null } | null;
+ };
+ expect(env.data!.healthScore).not.toBeNull();
+ const hs = env.data!.healthScore!;
+ // The BP pillar must score from the all-time window…
+ expect(hs.components.bp.value).not.toBeNull();
+ expect(hs.components.bp.value).toBeGreaterThanOrEqual(90);
+ expect(hs.components.bp.weight).toBeGreaterThan(0);
+ // …while the tile headline stays scoped to the trailing 30 days
+ // (no readings there → null), proving the two are decoupled.
+ expect(env.data!.bpInTargetPct).toBeNull();
+ });
+
it("returns null healthScore for a user with no data", async () => {
await seedSession("hs-empty");
const { GET } = await import("@/app/api/analytics/route");