Files
HealthLog/tests/integration/bp-in-target.test.ts
T
Marc-André BombeckandClaude Opus 4.7 577d8dd29f 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>
2026-05-09 23:28:54 +02:00

234 lines
8.3 KiB
TypeScript

/**
* Integration regression for the "BD-Zielbereich" / BP-in-target tile.
*
* Marc reported (v1.4.16 marathon) that the dashboard tile rendered
* 0 % despite multiple BP readings clearly inside the well-controlled
* range. v1.4.15 A4 had restructured the calculation but kept the
* ESH 2023 narrow-band semantics (`sysLow <= sys <= sysHigh AND diaLow
* <= dia <= diaHigh`) — which collapses to 0 % for any user whose
* readings sit BELOW the goal band, which is the most common case for
* normotensive HealthLog users.
*
* v1.4.16 A2 changes semantics to a one-sided "at or below ceiling
* with a hypotension floor" check. This test seeds a representative
* mix of readings against the real Postgres testcontainer + Prisma
* client and asserts the helper produces the expected percentage,
* guarding against future regressions where someone re-introduces a
* narrow-band check inline (we have 6 historical call sites — easy to
* miss one in review).
*
* Also covers the null-tolerance regression (NaN / out-of-floor) so
* the tile never crashes silently to 0 % from edge-case input.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("BP-in-target % — production data shape", () => {
it("computes a non-zero % for a normotensive user born under 65", async () => {
const prisma = getPrismaClient();
// Marc's actual production readings (anonymised but timestamps + values
// verbatim so a future reviewer can re-derive the expected count by
// hand from the test fixture). DOB: 1985-07-09 — under 65, so target
// is sysHigh = 129, diaHigh = 79.
const user = await prisma.user.create({
data: {
username: "bp-in-target-fixture",
email: "bp-in-target@example.test",
dateOfBirth: new Date("1985-07-09"),
},
});
const seedReadings: Array<{
sys: number;
dia: number;
measuredAt: string;
}> = [
{ sys: 117, dia: 79, measuredAt: "2026-05-08T07:38:22Z" }, // IN
{ sys: 122, dia: 86, measuredAt: "2026-05-03T21:22:02Z" }, // OUT (dia)
{ sys: 108, dia: 76, measuredAt: "2026-05-03T05:51:45Z" }, // IN
{ sys: 106, dia: 73, measuredAt: "2026-05-03T05:50:55Z" }, // IN
{ sys: 127, dia: 86, measuredAt: "2026-04-20T05:57:42Z" }, // OUT (dia)
{ sys: 115, dia: 78, measuredAt: "2026-04-18T06:59:29Z" }, // IN
{ sys: 108, dia: 75, measuredAt: "2026-04-16T05:24:51Z" }, // IN
{ sys: 124, dia: 82, measuredAt: "2026-04-15T05:34:35Z" }, // OUT (dia)
{ sys: 126, dia: 80, measuredAt: "2026-04-15T05:33:44Z" }, // OUT (dia)
{ sys: 133, dia: 95, measuredAt: "2026-04-15T20:52:26Z" }, // OUT (both)
];
// Seed both sys and dia rows at the same `measuredAt` so the helper's
// 5-minute pairing gate accepts every pair. This mirrors how the
// measurement form writes paired BP rows.
for (const r of seedReadings) {
const at = new Date(r.measuredAt);
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: r.sys,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: r.dia,
unit: "mmHg",
measuredAt: at,
},
});
}
const sysData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
select: { measuredAt: true, value: true },
});
const diaData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
select: { measuredAt: true, value: true },
});
const targets = getBpTargets(user.dateOfBirth);
expect(targets).not.toBeNull();
const result = computeBpInTargetPct(sysData, diaData, targets!);
expect(result).not.toBeNull();
expect(result!.pairs).toBe(10);
// 5 of 10 paired readings are at or below the ceiling and above
// the hypotension floor — see the `IN`/`OUT` annotations above.
// Pre-v1.4.16 (narrow-band semantics) would have reported 0 %.
expect(result!.pct).toBe(50);
});
it("returns null for a user with no BP measurements (tile renders 'no data')", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "bp-empty-fixture",
email: "bp-empty@example.test",
dateOfBirth: new Date("1985-07-09"),
},
});
const sysData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
select: { measuredAt: true, value: true },
});
const diaData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
select: { measuredAt: true, value: true },
});
const targets = getBpTargets(user.dateOfBirth);
expect(computeBpInTargetPct(sysData, diaData, targets!)).toBeNull();
});
/**
* Regression: a user with sys readings but missing dia (e.g., import
* partial-failure where only sys rows were written) must NOT crash
* the helper or silently return 0. We expect `null` so the dashboard
* tile renders the "no data" empty state.
*/
it("returns null when only sys readings exist (no dia to pair against)", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "bp-sys-only-fixture",
email: "bp-sys-only@example.test",
dateOfBirth: new Date("1985-07-09"),
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 122,
unit: "mmHg",
measuredAt: new Date("2026-05-08T08:00:00Z"),
},
});
const sysData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
select: { measuredAt: true, value: true },
});
const diaData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
select: { measuredAt: true, value: true },
});
const targets = getBpTargets(user.dateOfBirth);
expect(computeBpInTargetPct(sysData, diaData, targets!)).toBeNull();
});
/**
* Regression: a hypotensive reading (sys 80, dia 50) is NOT
* "well-controlled" — it's symptomatic low BP. The helper must mark
* it OUT of target despite being below the ceiling. The clinical
* floor (sys >= 90, dia >= 50) is what guards this.
*/
it("excludes symptomatic-hypotension readings from the in-target count", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "bp-hypo-fixture",
email: "bp-hypo@example.test",
dateOfBirth: new Date("1985-07-09"),
},
});
const seedReadings: Array<{ sys: number; dia: number }> = [
{ sys: 80, dia: 45 }, // hypotensive — out
{ sys: 122, dia: 75 }, // normal — in
];
for (const [i, r] of seedReadings.entries()) {
const at = new Date(`2026-05-0${i + 1}T08:00:00Z`);
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: r.sys,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: r.dia,
unit: "mmHg",
measuredAt: at,
},
});
}
const sysData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
select: { measuredAt: true, value: true },
});
const diaData = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
select: { measuredAt: true, value: true },
});
const targets = getBpTargets(user.dateOfBirth);
const result = computeBpInTargetPct(sysData, diaData, targets!);
expect(result).toEqual({ pct: 50, pairs: 2 });
});
});