fix(dashboard): wire 7T and 30T sub-values on the BD-Zielbereich tile

The tile rendered the 30-day headline correctly but the `7T:` and
`30T:` sub-values fell back to "—" even when the user had paired BP
readings in both windows. Root cause was that `/api/analytics` only
returned a single `bpInTargetPct`; the tile received `avg7={null}`
and `avg30={null}` and the TrendCard rendered the dash placeholder.

Add `computeBpInTargetWindows()` next to the existing `computeBpInTargetPct()`
helper. It filters the input series by `measuredAt` against the supplied
clock and runs the same pair-and-count logic over the trailing 7-day and
30-day windows. The route fetches the last 30 days of data once, derives
both shares from the result, and surfaces them as `bpInTargetPct7d` and
`bpInTargetPct30d` on the response. The dashboard tile maps them to
`avg7` and `avg30` so the sub-values now show the actual percentages.

Coverage:
- 6 unit tests for the windowed helper (empty input, mixed window data,
  null-7d-with-real-30d, default-clock, Marc's production fixture).
- 2 integration tests against testcontainers seeding 30 days of paired
  BP measurements; both windows produce the expected hand-counted shares.

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-10 10:14:11 +02:00
1 parent fc328abf49
commit 23363ca05e
5 files changed
+397 -5

No files matched your search

+26 -2
View File
@@ -4,7 +4,10 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
import {
computeBpInTargetPct,
computeBpInTargetWindows,
} from "@/lib/analytics/bp-in-target";
import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -54,9 +57,12 @@ export const GET = apiHandler(async () => {
// BP in-target percentage (auto-calculated from date of birth)
let bpInTargetPct: number | null = null;
let bpInTargetPct7d: number | null = null;
let bpInTargetPct30d: number | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const [sysData, diaData] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -83,6 +89,22 @@ export const GET = apiHandler(async () => {
// read 0 % even when readings clearly sat inside target.
const result = computeBpInTargetPct(sysData, diaData, bpTargets);
bpInTargetPct = result?.pct ?? null;
// v1.4.18 A1 — populate the dashboard tile's 7T / 30T sub-values.
// The headline (`bpInTargetPct`, also 30 days) is kept for backward
// compatibility with cached client bundles; the tile reads the
// explicit window fields below for the sub-row. `last30Days` here
// is the same calculation as the headline but kept independent so
// a future refactor (e.g. headline → "today's reading in target?")
// can move without breaking the sub-values.
const windows = computeBpInTargetWindows(
sysData,
diaData,
bpTargets,
now,
);
bpInTargetPct7d = windows.last7Days?.pct ?? null;
bpInTargetPct30d = windows.last30Days?.pct ?? null;
}
// Per-context glucose summaries (canonical mg/dL).
@@ -107,6 +129,8 @@ export const GET = apiHandler(async () => {
summaries: results,
bmi,
bpInTargetPct,
bpInTargetPct7d,
bpInTargetPct30d,
glucoseByContext,
});
});
+14 -2
View File
@@ -90,6 +90,14 @@ import {
interface AnalyticsData {
summaries: Record<string, DataSummary>;
bpInTargetPct: number | null;
/**
* v1.4.18 A1 — share of paired BP readings inside target over the
* last 7 / 30 days. Drive the BD-Zielbereich tile's `7T:` / `30T:`
* sub-values; render "—" when the field is null (no paired readings
* in the window).
*/
bpInTargetPct7d?: number | null;
bpInTargetPct30d?: number | null;
glucoseByContext?: Record<string, DataSummaryType>;
}
@@ -802,8 +810,12 @@ export default function DashboardPage() {
label={t("dashboard.bpInTarget")}
latest={data?.bpInTargetPct ?? null}
unit="%"
avg7={null}
avg30={null}
/* v1.4.18 A1 — wire 7T / 30T sub-values from the new
windowed analytics fields. Up to v1.4.17 these were
hard-coded to null and rendered "—" even when the
user had paired BP readings in both windows. */
avg7={data?.bpInTargetPct7d ?? null}
avg30={data?.bpInTargetPct30d ?? null}
slope30={null}
icon={Target}
directionSentiment="up-good"
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
computeBpInTargetPct,
computeBpInTargetWindows,
isBpReadingInTarget,
} from "../bp-in-target";
@@ -233,3 +234,139 @@ describe("computeBpInTargetPct", () => {
expect(result).toEqual({ pct: 0, pairs: 1 });
});
});
describe("computeBpInTargetWindows", () => {
/**
* v1.4.18 A1 regression — Marc reported the BD-Zielbereich tile shows
* the 30-day headline (50 %) but `7T: —` and `30T: —` placeholders
* even though the user has paired BP readings in both windows. Root
* cause: the analytics route only returned `bpInTargetPct` once over
* the last 30 days; the tile receives `avg7={null}, avg30={null}` and
* renders the "—" fallback. The fix is a windowed helper that returns
* both 7-day and 30-day shares so the tile can show all three.
*
* Pinning Marc's actual production fixture (10 paired readings over
* the last 30 days, 2 of them within the last 7 days) so a future
* reviewer can re-derive the expected counts by hand.
*/
const NOW = new Date("2026-05-09T12:00:00Z");
it("returns null windows when both series are empty", () => {
const result = computeBpInTargetWindows([], [], TARGETS_UNDER_65, NOW);
expect(result.last7Days).toBeNull();
expect(result.last30Days).toBeNull();
});
it("computes 7-day and 30-day shares from the same input", () => {
// Two readings inside the 7-day window: one in target, one out.
// Three additional readings older than 7 days but inside 30 days:
// two in target, one out. Expected: 7d = 50 % (1/2), 30d = 60 % (3/5).
const sys = [
// last-7d
reading("2026-05-08T07:38:22Z", 117), // IN
reading("2026-05-03T21:22:02Z", 145), // OUT (sys ceiling)
// 7d-30d
reading("2026-05-01T08:00:00Z", 122), // IN
reading("2026-04-28T08:00:00Z", 125), // IN
reading("2026-04-20T08:00:00Z", 133), // OUT
];
const dia = [
reading("2026-05-08T07:38:22Z", 79),
reading("2026-05-03T21:22:02Z", 90),
reading("2026-05-01T08:00:00Z", 75),
reading("2026-04-28T08:00:00Z", 78),
reading("2026-04-20T08:00:00Z", 95),
];
const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW);
expect(result.last7Days).toEqual({ pct: 50, pairs: 2 });
expect(result.last30Days).toEqual({ pct: 60, pairs: 5 });
});
it("returns null for the 7-day window when no readings fall inside it", () => {
// Only readings older than 7 days exist.
const sys = [
reading("2026-04-28T08:00:00Z", 122),
reading("2026-04-25T08:00:00Z", 125),
];
const dia = [
reading("2026-04-28T08:00:00Z", 75),
reading("2026-04-25T08:00:00Z", 78),
];
const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW);
expect(result.last7Days).toBeNull();
expect(result.last30Days).toEqual({ pct: 100, pairs: 2 });
});
it("returns null for both windows when only sys readings exist", () => {
const sys = [reading("2026-05-08T08:00:00Z", 122)];
const dia: ReturnType<typeof reading>[] = [];
const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW);
expect(result.last7Days).toBeNull();
expect(result.last30Days).toBeNull();
});
/**
* Defaults to `Date.now()` so callers don't have to thread the clock
* through. Spot-checked by passing a clock at the boundary of the
* 7-day window.
*/
it("defaults to Date.now() when no clock argument is supplied", () => {
const sys = [reading("2026-05-09T11:00:00Z", 122)];
const dia = [reading("2026-05-09T11:00:00Z", 75)];
// No clock argument — uses real Date.now(). The reading dates are
// in the future relative to Date.now() (post-test-suite), so they
// sit OUTSIDE the trailing 7-day window. We just assert it doesn't
// throw and produces deterministic shape.
const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65);
expect(result).toBeDefined();
expect("last7Days" in result).toBe(true);
expect("last30Days" in result).toBe(true);
});
/**
* Marc's real fixture: 10 paired readings over the last 30 days (5 in
* target, 5 out → 50 %), of which 2 are inside the last 7 days
* (1 in target, 1 out → 50 %).
*/
it("regression: Marc's production fixture renders 7d=50% and 30d=50%", () => {
const fixtureNow = new Date("2026-05-09T12:00:00Z");
const sys = [
reading("2026-05-08T07:38:22Z", 117), // 7d IN
reading("2026-05-03T21:22:02Z", 122), // 7d? (5d ago — yes, 7d) OUT (dia 86)
reading("2026-05-03T05:51:45Z", 108), // 7d IN
reading("2026-05-03T05:50:55Z", 106), // 7d IN
reading("2026-04-20T05:57:42Z", 127), // 30d OUT (dia 86)
reading("2026-04-18T06:59:29Z", 115), // 30d IN
reading("2026-04-16T05:24:51Z", 108), // 30d IN
reading("2026-04-15T05:34:35Z", 124), // 30d OUT (dia 82)
reading("2026-04-15T05:33:44Z", 126), // 30d OUT (dia 80)
reading("2026-04-15T20:52:26Z", 133), // 30d OUT (both)
];
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 = computeBpInTargetWindows(
sys,
dia,
TARGETS_UNDER_65,
fixtureNow,
);
// 4 paired readings inside the last 7 days (2026-05-08, 2026-05-03 x3).
// 3 of them are in target (117/79, 108/76, 106/73) → 75 %.
expect(result.last7Days).toEqual({ pct: 75, pairs: 4 });
// All 10 paired readings inside the last 30 days; 5 in target → 50 %.
expect(result.last30Days).toEqual({ pct: 50, pairs: 10 });
});
});
+52
View File
@@ -176,3 +176,55 @@ export function computeBpInTargetPct(
pairs,
};
}
/**
* v1.4.18 A1 — windowed in-target shares for the dashboard tile.
*
* The "BD im Zielbereich" tile renders three numbers:
* - Headline: the 30-day in-target % (the existing `computeBpInTargetPct`).
* - `7T:` the in-target % over the trailing 7 days.
* - `30T:` the in-target % over the trailing 30 days.
*
* Up to v1.4.17 only the 30-day headline was computed; the API returned
* a single `bpInTargetPct` and the tile rendered `avg7={null}` /
* `avg30={null}`, so the sub-values collapsed to "—" even when the user
* had paired readings in both windows. The fix surfaces both windows
* from a single helper that filters the input series by `measuredAt`
* relative to `now`. Caller can pass the unfiltered last-30-day data
* and get both windows back without a second DB round-trip.
*
* `null` for either window means "no paired readings in that window";
* the caller should render the "—" placeholder instead of `0`.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
export function computeBpInTargetWindows(
sysSeries: BpReading[],
diaSeries: BpReading[],
targets: BpTargets,
now: Date = new Date(),
): {
last7Days: { pct: number; pairs: number } | null;
last30Days: { pct: number; pairs: number } | null;
} {
const sevenDaysAgoMs = now.getTime() - 7 * DAY_MS;
const thirtyDaysAgoMs = now.getTime() - 30 * DAY_MS;
const sysLast7 = sysSeries.filter(
(r) => r.measuredAt.getTime() >= sevenDaysAgoMs,
);
const diaLast7 = diaSeries.filter(
(r) => r.measuredAt.getTime() >= sevenDaysAgoMs,
);
const sysLast30 = sysSeries.filter(
(r) => r.measuredAt.getTime() >= thirtyDaysAgoMs,
);
const diaLast30 = diaSeries.filter(
(r) => r.measuredAt.getTime() >= thirtyDaysAgoMs,
);
return {
last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets),
last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets),
};
}
+168 -1
View File
@@ -22,7 +22,10 @@
*/
import { beforeEach, describe, expect, it } from "vitest";
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
import {
computeBpInTargetPct,
computeBpInTargetWindows,
} from "@/lib/analytics/bp-in-target";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -231,3 +234,167 @@ describe("BP-in-target % — production data shape", () => {
expect(result).toEqual({ pct: 50, pairs: 2 });
});
});
describe("BP-in-target % — windowed (7-day + 30-day) — production data shape", () => {
/**
* v1.4.18 A1 regression — Marc's BD-Zielbereich tile rendered the
* 30-day headline (50 %) but `7T: —` and `30T: —` placeholders even
* though he had paired BP readings in both windows. Root cause was
* that the API only returned a single `bpInTargetPct`; the tile got
* `avg7={null}, avg30={null}` and rendered the dash fallback. The fix
* surfaces both windows from `computeBpInTargetWindows()` against the
* same input series.
*
* Seeds 30 days of paired readings (one per day, 8/30 = 26 % in
* target) and asserts the 7-day window vs 30-day window agree with a
* hand-derivable count.
*/
it("seeds 30 days of BP and returns non-null 7d + 30d shares", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "bp-windows-fixture",
email: "bp-windows@example.test",
dateOfBirth: new Date("1985-07-09"),
},
});
// Anchor the synthetic readings against a "now" that the helper can
// see — we seed at fractional-day offsets so the boundary of the
// 7-day window (`>= now - 7d`) is unambiguous. Days 0.5..6.5 sit
// strictly inside the 7-day window; days 7.5..29.5 sit strictly
// outside it but inside the 30-day window.
const now = new Date();
// A small mix:
// - days 0.5-6.5 (7-day window): 7 readings, 4 in target.
// - days 7.5-29.5 (older 30-day window only): 22 readings,
// 11 in target (alternating, starting in-target).
// Expected windows:
// last7Days: 4/7 = 57 % (rounded).
// last30Days: 15/29 = 52 % (rounded).
const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [
// 7-day window (4 IN, 3 OUT)
{ daysAgo: 0.5, sys: 117, dia: 79 }, // IN
{ daysAgo: 1.5, sys: 122, dia: 86 }, // OUT (dia)
{ daysAgo: 2.5, sys: 108, dia: 76 }, // IN
{ daysAgo: 3.5, sys: 145, dia: 95 }, // OUT (both)
{ daysAgo: 4.5, sys: 115, dia: 78 }, // IN
{ daysAgo: 5.5, sys: 124, dia: 82 }, // OUT (dia)
{ daysAgo: 6.5, sys: 125, dia: 75 }, // IN
// older 7-30-day window (alternating, 11 IN / 11 OUT)
...Array.from({ length: 22 }, (_, i) => {
const daysAgo = 7.5 + i;
const isIn = i % 2 === 0;
return isIn
? { daysAgo, sys: 122, dia: 75 }
: { daysAgo, sys: 145, dia: 90 };
}),
];
for (const r of seed) {
const at = new Date(
now.getTime() - r.daysAgo * 24 * 60 * 60 * 1000,
);
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 windows = computeBpInTargetWindows(sysData, diaData, targets!, now);
// Both windows must produce a value — that's the regression.
expect(windows.last7Days).not.toBeNull();
expect(windows.last30Days).not.toBeNull();
// Hand-counted: 7-day window = 4/7 paired in target.
expect(windows.last7Days!.pairs).toBe(7);
expect(windows.last7Days!.pct).toBe(Math.round((4 / 7) * 100));
// 30-day window = 7 (last week) + 22 (older) = 29 paired readings.
// In-target: 4 (last week) + 11 (older, even-indexed alternation
// for i ∈ {0,2,4,...,20} = 11) = 15.
expect(windows.last30Days!.pairs).toBe(29);
expect(windows.last30Days!.pct).toBe(Math.round((15 / 29) * 100));
});
/**
* Regression: a user with all readings older than 7 days but inside
* 30 days must show a 30-day percentage AND a null 7-day window
* (the tile renders "—" for 7T but a real number for 30T). Pre-fix
* both rendered "—" because neither was computed at all.
*/
it("returns null 7-day window but a real 30-day window when no recent data", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "bp-no-recent-fixture",
email: "bp-no-recent@example.test",
dateOfBirth: new Date("1985-07-09"),
},
});
const now = new Date();
const fourteenDaysAgo = new Date(
now.getTime() - 14 * 24 * 60 * 60 * 1000,
);
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
value: 122,
unit: "mmHg",
measuredAt: fourteenDaysAgo,
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
value: 75,
unit: "mmHg",
measuredAt: fourteenDaysAgo,
},
});
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 windows = computeBpInTargetWindows(sysData, diaData, targets!, now);
expect(windows.last7Days).toBeNull();
expect(windows.last30Days).toEqual({ pct: 100, pairs: 1 });
});
});