From 016f866965f13bf3d3fc07efd41499c622bbb685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:47:39 +0200 Subject: [PATCH 1/2] fix(insights): health-score BP pillar from all-time window + surface ACTIVITY_STEPS in Insights --- docs/api/openapi.yaml | 2 + messages/de.json | 10 ++++ messages/en.json | 10 ++++ messages/es.json | 10 ++++ messages/fr.json | 10 ++++ messages/it.json | 10 ++++ messages/pl.json | 10 ++++ src/app/api/analytics/route.ts | 30 +++++++++- src/app/insights/steps/page.tsx | 36 ++++++++++++ .../__tests__/insights-tab-strip.test.tsx | 16 ++++++ .../insights/insights-tab-strip.tsx | 4 ++ .../__tests__/metric-availability.test.ts | 22 +++++++ src/lib/insights/metric-availability.ts | 8 +++ src/lib/insights/sub-page-metric.ts | 6 ++ .../analytics-health-score.test.ts | 57 +++++++++++++++++++ 15 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/app/insights/steps/page.tsx 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"); From b5e106776f90492bca80332b1a5bebbb726298bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:53:15 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=20v1.13.3=20=E2=80=94=20hea?= =?UTF-8?q?lth=20score=20rates=20blood=20pressure=20again,=20steps=20in=20?= =?UTF-8?q?Insights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Health score reads the all-time blood-pressure-in-range rate (not the trailing 30 days), so an account with BP history but no recent readings keeps its BP pillar instead of showing "no rating"; the 30-day rate still drives the tile headline. (Regression from the v1.8.7 BP-headline window change.) - ACTIVITY_STEPS now has its own Insights sub-page + nav entry (it was tracked + on the dashboard but absent from the Insights metric registry). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b22fac5bf..d66557cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.13.3] — 2026-06-05 — health score scores blood pressure again, steps in Insights + +### Fixed + +- **The health score rates your blood pressure again.** The score was reading your blood-pressure-in-range rate from the trailing 30 days, but the score is meant to use your full history — so an account with blood-pressure history but no readings in the last month lost the whole blood-pressure pillar and showed "no rating". The score now reads the all-time rate (the at-a-glance tile headline still shows the last 30 days). If your blood pressure or weight still shows no rating, check that your date of birth (needed for the blood-pressure target) and height + target weight are set in your profile — those are required for the respective pillars. + +### Added + +- **Steps now have their own Insights page.** "Activity / Steps" was tracked and shown on the dashboard but never appeared in the Insights metric navigation; it now has its own Insights sub-page like the other activity metrics, surfaced whenever you have step data. + ## [1.13.2] — 2026-06-05 — honest weekly-med compliance, correct sleep average, calmer wellness scores ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 80c9b5f03..b0120b86a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.13.2 + version: 1.13.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 13715e11a..784508b61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.13.2", + "version": "1.13.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",