mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge pull request #273 from MBombeck/release/v1.13.3
release/v1.13.3 — health-score BP pillar + steps in Insights
This commit is contained in:
17 files changed
+250
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<HealthKitMetricPage
|
||||
measurementType="ACTIVITY_STEPS"
|
||||
statusMetric="STEPS"
|
||||
insightMetric="ACTIVITY_STEPS"
|
||||
chartKey="steps"
|
||||
i18nPrefix="insights.steps"
|
||||
statIcon={Footprints}
|
||||
color="#50fa7b"
|
||||
unit="steps"
|
||||
yAxisUnit="steps"
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -177,6 +177,22 @@ describe("<InsightsTabStrip> — 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(<InsightsTabStrip availability={availability} />);
|
||||
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) },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Record<SubPageSlug, SubPageGroup>> = {
|
||||
"visceral-fat": "body",
|
||||
"lean-body-mass": "body",
|
||||
// activity / mobility (v1.7.0)
|
||||
steps: "activity",
|
||||
"flights-climbed": "activity",
|
||||
"walking-distance": "activity",
|
||||
"walking-steadiness": "activity",
|
||||
|
||||
@@ -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<Response>)(
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user