mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
e22d731f31
commit
577d8dd29f
9 files changed
+448
-68
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 +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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user