diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 8bb9a443c..83e751641 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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, }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index c13f76704..e388a04a5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -90,6 +90,14 @@ import { interface AnalyticsData { summaries: Record; 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; } @@ -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" diff --git a/src/lib/analytics/__tests__/bp-in-target.test.ts b/src/lib/analytics/__tests__/bp-in-target.test.ts index 4f04deef9..bd95ccfbf 100644 --- a/src/lib/analytics/__tests__/bp-in-target.test.ts +++ b/src/lib/analytics/__tests__/bp-in-target.test.ts @@ -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[] = []; + 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 }); + }); +}); diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index 54b53df02..06528cfd4 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -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), + }; +} diff --git a/tests/integration/bp-in-target.test.ts b/tests/integration/bp-in-target.test.ts index 5aec57b15..c70b7a249 100644 --- a/tests/integration/bp-in-target.test.ts +++ b/tests/integration/bp-in-target.test.ts @@ -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 }); + }); +});