fix(insights): BD-Zielbereich computes correctly for real measurement data

The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.

Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".

Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.

Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
  exact production fixture (10 paired readings -> 50% under v1.4.16,
  was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
  testcontainer with Marc's reading shape + null-tolerance + hypotension
  edge cases.

Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 23:28:54 +02:00
1 parent e22d731f31
commit 577d8dd29f
9 files changed
+448 -68

No files matched your search

+5 -6
View File
@@ -12,6 +12,7 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
generateAlerts,
type HealthAlert,
@@ -112,12 +113,10 @@ export const GET = apiHandler(async () => {
const diaData = byType("BLOOD_PRESSURE_DIA");
const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000);
if (pairs.length > 0) {
const inTarget = pairs.filter(
(p) =>
p.a >= bpTargets.sysLow &&
p.a <= bpTargets.sysHigh &&
p.b >= bpTargets.diaLow &&
p.b <= bpTargets.diaHigh,
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
const inTarget = pairs.filter((p) =>
isBpReadingInTarget(p.a, p.b, bpTargets),
).length;
bpPctInTarget = Math.round((inTarget / pairs.length) * 100);
}
+5 -6
View File
@@ -3,6 +3,7 @@ import { resolveProvider } from "@/lib/ai/provider";
import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
classifyBMI,
classifyBP,
@@ -121,12 +122,10 @@ export const GET = apiHandler(async () => {
const sysPairs = pairByTimestamp(sysData, diaData, 5 * 60 * 1000);
if (sysPairs.length > 0) {
const inTarget = sysPairs.filter(
(p) =>
p.a >= bpTargets.sysLow &&
p.a <= bpTargets.sysHigh &&
p.b >= bpTargets.diaLow &&
p.b <= bpTargets.diaHigh,
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
const inTarget = sysPairs.filter((p) =>
isBpReadingInTarget(p.a, p.b, bpTargets),
).length;
bpPctInTarget = Math.round((inTarget / sysPairs.length) * 100);
}
+5 -6
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/analytics/classifications";
import { calculateCompliance } from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
classifyPulseByTarget,
getPersonalizedPulseTarget,
@@ -228,12 +229,10 @@ export const GET = apiHandler(async () => {
}));
const bpPairs = pairByTimestamp(sysPoints, diaPoints, 5 * 60 * 1000);
const bpInTargetCount = bpPairs.filter(
(pair) =>
pair.a >= bpRange.sysLow &&
pair.a <= bpRange.sysHigh &&
pair.b >= bpRange.diaLow &&
pair.b <= bpRange.diaHigh,
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
const bpInTargetCount = bpPairs.filter((pair) =>
isBpReadingInTarget(pair.a, pair.b, bpRange),
).length;
const bpInTargetRate =
bpPairs.length > 0