fix(insights): health-score BP pillar from all-time window + surface ACTIVITY_STEPS in Insights

This commit is contained in:
Marc-André Bombeck committed 2026-06-05 22:47:46 +02:00
1 parent 3c87ee9230
commit 016f866965
15 files changed
+238 -3

No files matched your search

+27 -3
View File
@@ -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,
+36
View File
@@ -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);
});
});
+8
View File
@@ -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"
+6
View File
@@ -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",