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
@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { computeBpInTargetPct } from "../bp-in-target";
import {
computeBpInTargetPct,
isBpReadingInTarget,
} from "../bp-in-target";
const TARGETS_UNDER_65 = {
sysLow: 120,
@@ -12,6 +15,48 @@ function reading(measuredAt: string, value: number) {
return { measuredAt: new Date(measuredAt), value };
}
describe("isBpReadingInTarget()", () => {
/**
* v1.4.16 A2 regression: 117/79 is textbook normotensive
* (well-controlled, below the goal ceiling, above hypotension). The
* v1.4.15 implementation rejected this because sys < sysLow (120),
* which collapsed Marc's BD-Zielbereich tile to 0 %. The fix uses a
* one-sided ceiling check with a clinical floor.
*/
it("counts a normotensive reading below the goal band as in-target", () => {
expect(isBpReadingInTarget(117, 79, TARGETS_UNDER_65)).toBe(true);
});
it("counts the goal band itself (exact) as in-target", () => {
expect(isBpReadingInTarget(125, 75, TARGETS_UNDER_65)).toBe(true);
expect(isBpReadingInTarget(120, 70, TARGETS_UNDER_65)).toBe(true);
expect(isBpReadingInTarget(129, 79, TARGETS_UNDER_65)).toBe(true);
});
it("rejects sys above the upper bound", () => {
expect(isBpReadingInTarget(140, 75, TARGETS_UNDER_65)).toBe(false);
expect(isBpReadingInTarget(130, 75, TARGETS_UNDER_65)).toBe(false);
});
it("rejects dia above the upper bound", () => {
expect(isBpReadingInTarget(125, 90, TARGETS_UNDER_65)).toBe(false);
expect(isBpReadingInTarget(125, 80, TARGETS_UNDER_65)).toBe(false);
});
it("rejects readings under the symptomatic-hypotension floor", () => {
// Sys 80 = stage-1 hypotension territory; not "well-controlled".
expect(isBpReadingInTarget(80, 60, TARGETS_UNDER_65)).toBe(false);
// Dia 45 = circulatory-collapse risk; not in-target either.
expect(isBpReadingInTarget(110, 45, TARGETS_UNDER_65)).toBe(false);
});
it("counts the clinical floor itself (exact) as in-target", () => {
// 90/50 is the lowest plausible normotensive resting band — barely
// counted as in-target, anything lower is rejected.
expect(isBpReadingInTarget(90, 50, TARGETS_UNDER_65)).toBe(true);
});
});
describe("computeBpInTargetPct", () => {
it("returns null when either series is empty", () => {
expect(computeBpInTargetPct([], [], TARGETS_UNDER_65)).toBeNull();
@@ -31,8 +76,8 @@ describe("computeBpInTargetPct", () => {
reading("2026-05-02T08:00:00Z", 145), // out of target
];
const dia = [
reading("2026-05-01T08:00:30Z", 75), // in target → pair in
reading("2026-05-02T08:01:00Z", 90), // out of target → pair out
reading("2026-05-01T08:00:30Z", 75), // in target -> pair in
reading("2026-05-02T08:01:00Z", 90), // out of target -> pair out
];
const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65);
@@ -88,7 +133,7 @@ describe("computeBpInTargetPct", () => {
const dia = [
reading("2026-05-01T08:00:30Z", 75), // pairs with first sys
// gap on 2026-05-02 — second sys has no same-day dia
reading("2026-05-10T08:00:30Z", 75), // 8 days away from second sys → not paired
reading("2026-05-10T08:00:30Z", 75), // 8 days away from second sys -> not paired
];
const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65);
// First sys pair is in target. Second sys's closest dia (2026-05-10)
@@ -107,16 +152,84 @@ describe("computeBpInTargetPct", () => {
it("counts boundary values as in-target", () => {
const sys = [
reading("2026-05-01T08:00:00Z", 120), // sysLow exact
reading("2026-05-02T08:00:00Z", 129), // sysHigh exact
reading("2026-05-01T08:00:00Z", 120), // sysLow exact (still in)
reading("2026-05-02T08:00:00Z", 129), // sysHigh exact (still in)
];
const dia = [
reading("2026-05-01T08:00:00Z", 70), // diaLow exact
reading("2026-05-02T08:00:00Z", 79), // diaHigh exact
reading("2026-05-01T08:00:00Z", 70), // diaLow exact (still in)
reading("2026-05-02T08:00:00Z", 79), // diaHigh exact (still in)
];
expect(computeBpInTargetPct(sys, dia, TARGETS_UNDER_65)).toEqual({
pct: 100,
pairs: 2,
});
});
/**
* v1.4.16 A2 regression — Marc's actual production data.
*
* Marc reported BD-Zielbereich = 0 % despite multiple BP readings
* that "are clearly in target". A query against production showed
* his last 30 days of paired readings include 117/79, 122/76, 108/76,
* 106/73, 127/86, 115/78, 108/75, 124/82, 126/80, 133/95.
*
* Under v1.4.15 narrow-band semantics (sys >= 120 AND sys <= 129 AND
* dia >= 70 AND dia <= 79) every single one of those reads as OUT of
* target — sys < 120 in 5 of 10, dia > 79 in 4 of 10, both out in 1.
* Result: 0/10 = 0 %.
*
* Under v1.4.16 ceiling semantics (sys <= sysHigh AND dia <= diaHigh
* with hypotension floor) the readings 117/79, 108/76, 106/73, 115/78,
* 108/75 are IN target (sys <= 129 and dia <= 79) and 122/86, 127/86,
* 124/82, 126/80, 133/95 are OUT (dia > 79 or sys > 129).
* Result: 5/10 = 50 %.
*
* Compare with the v1.4.15 narrow-band result (0/10 = 0 %) — that's
* the regression Marc reported.
*/
it("regression: Marc's production data produces non-zero % under the ceiling semantics", () => {
const sys = [
reading("2026-05-08T07:38:22Z", 117),
reading("2026-05-03T21:22:02Z", 122),
reading("2026-05-03T05:51:45Z", 108),
reading("2026-05-03T05:50:55Z", 106),
reading("2026-04-20T05:57:42Z", 127),
reading("2026-04-18T06:59:29Z", 115),
reading("2026-04-16T05:24:51Z", 108),
reading("2026-04-15T05:34:35Z", 124),
reading("2026-04-15T05:33:44Z", 126),
reading("2026-04-15T20:52:26Z", 133),
];
const dia = [
reading("2026-05-08T07:38:22Z", 79),
reading("2026-05-03T21:22:02Z", 86),
reading("2026-05-03T05:51:45Z", 76),
reading("2026-05-03T05:50:55Z", 73),
reading("2026-04-20T05:57:42Z", 86),
reading("2026-04-18T06:59:29Z", 78),
reading("2026-04-16T05:24:51Z", 75),
reading("2026-04-15T05:34:35Z", 82),
reading("2026-04-15T05:33:44Z", 80),
reading("2026-04-15T20:52:26Z", 95),
];
const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65);
expect(result).not.toBeNull();
expect(result!.pairs).toBe(10);
// 5/10 in target: 117/79, 108/76, 106/73, 115/78, 108/75.
expect(result!.pct).toBe(50);
});
/**
* Regression: a NaN value (e.g., from a corrupt import row that was
* stored as a non-numeric column). Not a real-world case in current
* code paths since Prisma's Float column rejects NaN, but defensive
* coverage in case downstream callers ever inject one.
*/
it("does not crash on NaN values (treats them as out-of-target)", () => {
const sys = [reading("2026-05-01T08:00:00Z", Number.NaN)];
const dia = [reading("2026-05-01T08:00:30Z", 75)];
const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65);
// Pair forms, NaN <= sysHigh is false, so 0/1 = 0 %.
expect(result).toEqual({ pct: 0, pairs: 1 });
});
});
+66 -26
View File
@@ -2,32 +2,45 @@
* BP-in-target percentage helper.
*
* The "BD im Zielbereich" tile on the dashboard reports the share of
* recent blood-pressure readings that fall inside the user's age-band
* target zone. Up to v1.4.14 this lived inline in
* recent blood-pressure readings that are at or below the user's
* age-band ceiling. Up to v1.4.14 this lived inline in
* `src/app/api/analytics/route.ts` and had two latent bugs that made
* the tile read 0 % even when the user clearly had readings inside the
* target band:
*
* 1. **Wrong denominator.** The legacy code divided `inTarget /
* sysData.length`, but readings are only countable when a matching
* diastolic exists within 5 minutes. So a user with 30 sys + 30 dia
* that all paired and 6 in target read `6/30 = 20 %` — but a user
* where only 10 of 30 sys had a matching dia inside 5 minutes (the
* other 20 were close-pair-less imports) capped at `<= 10/30 = 33 %`
* no matter what. Worse: when none paired, the numerator stayed 0
* so the tile reported a flat 0 %.
* 1. **Wrong denominator (v1.4.14).** The legacy code divided
* `inTarget / sysData.length`, but readings are only countable when
* a matching diastolic exists within 5 minutes. v1.4.15 A4 fixed the
* denominator to count only paired readings.
*
* 2. **No fallback when timestamps don't pair.** Withings imports and
* moodLog.app imports each write sys + dia as separate Measurement
* rows that ought to share a `measuredAt` to the second — but
* rounding through different timezones can drift them by minutes.
* The 5-minute window was tight; data imported with hour-level
* rounding (some legacy Withings ranges) had `timeDiff > 5 min` and
* the tile flat-lined to 0 %.
* 2. **Wrong target semantics (v1.4.15 -> v1.4.16 A2 regression).** The
* v1.4.15 fix kept the original "within narrow goal band" semantics:
* `sys >= sysLow && sys <= sysHigh && dia >= diaLow && dia <= diaHigh`.
* With ESH 2023 narrow targets (Sys 120-129, Dia 70-79 for under-65)
* this collapses to 0 % for healthy normotensive users whose
* readings sit BELOW the goal band — Marc's actual data has many
* readings like 117/79 (textbook normotensive, well-controlled) that
* were marked OUT of target because sys < 120. Users intuitively
* consider those readings *in target* — and so does clinical
* practice ("BP is well-controlled" = "at goal or below the goal
* ceiling, not implausibly hypotensive"). The v1.4.16 fix changes
* semantics from a narrow band to a one-sided ceiling check with a
* clinical floor for true hypotension:
*
* Fix: pair sys + dia by **same Berlin calendar day** as a fallback if
* the within-5-minutes pairing yields nothing, and always divide by the
* count of *paired* readings (the denominator that gets a vote).
* in-target := sys >= 90 AND sys <= sysHigh
* AND dia >= 50 AND dia <= diaHigh
*
* The 90/50 floors are well below any plausible "normal-low" and
* flag symptomatic hypotension as out-of-target instead of silently
* counting it as good control.
*
* 3. **No fallback when timestamps don't pair (v1.4.15 fix).** Withings
* imports and moodLog.app imports each write sys + dia as separate
* Measurement rows that ought to share a `measuredAt` to the second
* — but rounding through different timezones can drift them by
* minutes. The 5-minute window was tight; data imported with
* hour-level rounding (some legacy Withings ranges) had `timeDiff >
* 5 min`. The fallback to same-Berlin-day pairing covers that.
*
* Pure & deterministic so the unit test suite can pin the exact %.
*/
@@ -38,6 +51,15 @@ export interface BpReading {
value: number;
}
/**
* Clinical floors below which a reading is symptomatic hypotension and
* NOT a desirable "well-controlled BP" outcome. Roughly mid-band
* shock-onset thresholds; well below any reasonable normotensive
* resting reading.
*/
const SYS_HYPOTENSION_FLOOR = 90;
const DIA_HYPOTENSION_FLOOR = 50;
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
@@ -77,6 +99,26 @@ function findClosestDia(
return { dia: bestDia, deltaMs: bestDelta };
}
/**
* `true` when a paired reading is at or below the upper bound for both
* sys and dia, AND above the clinical hypotension floors. This is the
* canonical "BP in target" definition shared across every call site
* (dashboard tile, insight cards, AI snapshot, comprehensive endpoint,
* targets endpoint). Exported so the other modules don't drift.
*/
export function isBpReadingInTarget(
sys: number,
dia: number,
targets: BpTargets,
): boolean {
return (
sys >= SYS_HYPOTENSION_FLOOR &&
sys <= targets.sysHigh &&
dia >= DIA_HYPOTENSION_FLOOR &&
dia <= targets.diaHigh
);
}
/**
* Compute the share (0-100, rounded to nearest integer) of paired BP
* readings inside `targets` over the supplied series. Returns `null`
@@ -95,6 +137,9 @@ function findClosestDia(
* Denominator is the number of accepted pairs, NOT `sysData.length` —
* that was the v1.4.14 bug that made the tile read 0 % when imports
* had drifted timestamps.
*
* In-target check is the one-sided ceiling defined by
* `isBpReadingInTarget()` — see file-header comment for the rationale.
*/
export function computeBpInTargetPct(
sysSeries: BpReading[],
@@ -119,12 +164,7 @@ export function computeBpInTargetPct(
if (!sameSession && !sameBerlinDay) continue;
pairs += 1;
if (
sys.value >= targets.sysLow &&
sys.value <= targets.sysHigh &&
match.dia.value >= targets.diaLow &&
match.dia.value <= targets.diaHigh
) {
if (isBpReadingInTarget(sys.value, match.dia.value, targets)) {
inTarget += 1;
}
}
+4 -4
View File
@@ -9,6 +9,7 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
pearsonCorrelation,
type PairedPoint,
@@ -236,10 +237,9 @@ export async function generateBloodPressureStatusForUser(
inTarget:
bpTargets == null
? null
: entry.a >= bpTargets.sysLow &&
entry.a <= bpTargets.sysHigh &&
entry.b >= bpTargets.diaLow &&
entry.b <= bpTargets.diaHigh,
: // v1.4.16 A2 — one-sided "at or below ceiling" semantics with a
// clinical hypotension floor. See lib/analytics/bp-in-target.ts.
isBpReadingInTarget(entry.a, entry.b, bpTargets),
}));
const bpInTargetPctLast30DailyPoints =
+4 -6
View File
@@ -8,6 +8,7 @@ import { summarize } from "@/lib/analytics/trends";
import type { DataPoint } from "@/lib/analytics/trends";
import { calculateCompliance } from "@/lib/analytics/compliance";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
pairByTimestamp,
pearsonCorrelation,
@@ -369,12 +370,9 @@ export async function extractFeatures(
const sysVal = sysByTime.get(dia.measuredAt.getTime());
if (sysVal === undefined) continue;
pairedCount++;
if (
sysVal >= bpTargets.sysLow &&
sysVal <= bpTargets.sysHigh &&
dia.value >= bpTargets.diaLow &&
dia.value <= bpTargets.diaHigh
) {
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
if (isBpReadingInTarget(sysVal, dia.value, bpTargets)) {
inTargetCount++;
}
}
+5 -6
View File
@@ -5,6 +5,7 @@ import {
getGeneralStatusUserPrompt,
} from "@/lib/ai/prompts/general-status";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
@@ -258,12 +259,10 @@ export async function generateGeneralStatusForUser(
.filter((point) => point.dayOffset < 30);
if (paired.length > 0) {
const inTargetCount = paired.filter(
(point) =>
point.sys >= bpTargets.sysLow &&
point.sys <= bpTargets.sysHigh &&
point.dia >= bpTargets.diaLow &&
point.dia <= bpTargets.diaHigh,
const inTargetCount = paired.filter((point) =>
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
isBpReadingInTarget(point.sys, point.dia, bpTargets),
).length;
bpInTargetLast30Days = round((inTargetCount / paired.length) * 100, 1);
}
+233
View File
@@ -0,0 +1,233 @@
/**
* 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 });
});
});