diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index 6a73d23e3..2a9c0c862 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -181,6 +181,40 @@ describe("v1.4.19 A3 — chart-token leak hardening", () => { }); }); +describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta uses period-aligned baseline", () => { + /** + * Up to v1.4.22 W4 the BD tile computed `bp30 - bpAll` + * (last-30-days minus all-time) regardless of which window the user + * picked under Settings → Dashboard. The caption still rendered "vs. + * last month" / "vs. last year" via `comparison.captionLastMonth` / + * `comparison.captionLastYear`, so the user read a sentence whose + * numerator was actually a 30d-vs-all-time delta. Every other tile + * routes through `tileCompareDelta()` with `summary.avg30LastMonth` + * / `summary.avg30LastYear` for honest period-aligned math. + * + * This test pins that the dashboard now subtracts the prior-period + * window (priorMonth / priorYear) instead of all-time. + */ + it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => { + const src = load(DASHBOARD_PATH); + // The fix introduces `bpComparePrior` keyed by `compareBaseline`. + expect(src).toContain("bpInTargetPctPriorMonth"); + expect(src).toContain("bpInTargetPctPriorYear"); + // The all-time shortcut from v1.4.22 A2 must not subtract `bpAll` + // for the comparison delta any more. + expect(src).not.toMatch(/bp30\s*-\s*bpAll/); + }); + + it("ships priorMonth + priorYear pcts in the analytics envelope", () => { + const ROUTE_SRC = readFileSync( + join(ROOT, "src/app/api/analytics/route.ts"), + "utf8", + ); + expect(ROUTE_SRC).toContain("bpInTargetPctPriorMonth"); + expect(ROUTE_SRC).toContain("bpInTargetPctPriorYear"); + }); +}); + describe("v1.4.22 A3 — comparison toggle is global Settings only", () => { /** * The comparison-overlay toggle is a global preference. Up to v1.4.21 diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 39a990a22..38e16e5cc 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -70,6 +70,16 @@ export const GET = apiHandler(async () => { let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; let bpInTargetPctAllTime: number | null = null; + /** + * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window + * pcts so the BD-Zielbereich tile's comparison-overlay caption + * stops mismatching its math with its label. The tile's + * `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`) + * matching the user's `comparisonBaseline` selection, never + * `last30Days - allTime` (the v1.4.22 A2 shortcut). + */ + let bpInTargetPctPriorMonth: number | null = null; + let bpInTargetPctPriorYear: number | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); @@ -99,6 +109,8 @@ export const GET = apiHandler(async () => { bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; + bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; + bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). @@ -153,6 +165,8 @@ export const GET = apiHandler(async () => { bpInTargetPct7d, bpInTargetPct30d, bpInTargetPctAllTime, + bpInTargetPctPriorMonth, + bpInTargetPctPriorYear, glucoseByContext, correlations, healthScore, diff --git a/src/app/page.tsx b/src/app/page.tsx index 0857c570f..345e16476 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -103,6 +103,15 @@ interface AnalyticsData { * on the BD-Zielbereich tile (alongside `7d` and `30d`). */ bpInTargetPctAllTime?: number | null; + /** + * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window + * pcts. The BD-Zielbereich tile's comparison-overlay caption picks + * `priorMonth` for `comparisonBaseline === "lastMonth"` and + * `priorYear` for `lastYear` so the rendered "Δ X% vs. last month" + * stays honest. Null when the prior window has no paired readings. + */ + bpInTargetPctPriorMonth?: number | null; + bpInTargetPctPriorYear?: number | null; glucoseByContext?: Record; } @@ -817,6 +826,8 @@ export default function DashboardPage() { const bp7 = data?.bpInTargetPct7d ?? null; const bp30 = data?.bpInTargetPct30d ?? null; const bpAll = data?.bpInTargetPctAllTime ?? null; + const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null; + const bpPriorYear = data?.bpInTargetPctPriorYear ?? null; const bpTrendDelta = bp7 !== null && bp30 !== null ? bp7 - bp30 : null; const bpSlope30: import("@/lib/analytics/trends").TrendSlope | null = @@ -835,10 +846,26 @@ export default function DashboardPage() { // mis-interpret a `0` as "very low confidence". confidence: 1, }; + // v1.4.22 W5 reconcile (Code-H2) — match the comparison + // window the user picked. `lastMonth` baseline ⇒ compare + // last30 against the now-60d…now-30d window (priorMonth); + // `lastYear` baseline ⇒ compare against the matching window + // shifted back 365 days. The previous shortcut subtracted + // `bpAll` regardless of baseline, which produced a number + // whose magnitude was honest but whose label ("vs. last + // month") lied to the user. + const bpComparePrior = + compareBaseline === "lastMonth" + ? bpPriorMonth + : compareBaseline === "lastYear" + ? bpPriorYear + : null; const bpCompareDelta = - compareBaseline === "none" || bp30 === null || bpAll === null + compareBaseline === "none" || + bp30 === null || + bpComparePrior === null ? null - : Math.round((bp30 - bpAll) * 10) / 10; + : Math.round((bp30 - bpComparePrior) * 10) / 10; trendCards.push({ id: "bpInTarget", order: widgetOrder("bpInTarget"), diff --git a/src/lib/analytics/__tests__/bp-in-target.test.ts b/src/lib/analytics/__tests__/bp-in-target.test.ts index d9679a172..e25906e61 100644 --- a/src/lib/analytics/__tests__/bp-in-target.test.ts +++ b/src/lib/analytics/__tests__/bp-in-target.test.ts @@ -476,3 +476,63 @@ describe("computeBpInTargetWindows", () => { expect(result.last30Days).toEqual({ pct: 50, pairs: 10 }); }); }); + +describe("computeBpInTargetWindows — period-aligned prior windows (Code-H2)", () => { + /** + * v1.4.22 W5 reconcile (Code-H2) — the BD-Zielbereich tile's + * comparison-overlay caption used to compute `last30Days - allTime` + * regardless of the user's `comparisonBaseline` selection. The math + * therefore claimed "vs. last month" while subtracting an all-time + * average — dishonest by every other tile's standard. The fix is a + * pair of period-aligned prior windows shifted back by 30 / 365 + * days so the tile's `compareDelta` matches its caption. + */ + const NOW = new Date("2026-05-09T12:00:00Z"); + + it("returns priorMonth shifted back by 30 days (now-60d…now-30d)", () => { + // 30-day window: only one IN paired reading. + // Prior-month window (now-60d…now-30d): two paired readings, one IN one OUT. + const sys = [ + reading("2026-05-01T08:00:00Z", 117), // last30 IN + reading("2026-04-08T08:00:00Z", 122), // priorMonth IN (32d ago) + reading("2026-03-22T08:00:00Z", 145), // priorMonth OUT (48d ago) + reading("2026-02-08T08:00:00Z", 128), // older than 60d — neither window + ]; + const dia = [ + reading("2026-05-01T08:00:00Z", 75), + reading("2026-04-08T08:00:00Z", 75), + reading("2026-03-22T08:00:00Z", 92), + reading("2026-02-08T08:00:00Z", 80), + ]; + + const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW); + expect(result.last30Days).toEqual({ pct: 100, pairs: 1 }); + expect(result.priorMonth).toEqual({ pct: 50, pairs: 2 }); + }); + + it("returns priorYear shifted back by 365 days (now-395d…now-365d)", () => { + // Pin one paired reading inside the now-395d…now-365d window and one + // outside it. + const sys = [ + reading("2025-05-08T08:00:00Z", 117), // 366d ago — INSIDE priorYear, IN + reading("2025-04-30T08:00:00Z", 145), // 374d ago — INSIDE priorYear, OUT + reading("2025-04-01T08:00:00Z", 117), // 403d ago — OUTSIDE priorYear (too old) + ]; + const dia = [ + reading("2025-05-08T08:00:00Z", 75), + reading("2025-04-30T08:00:00Z", 92), + reading("2025-04-01T08:00:00Z", 75), + ]; + + const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW); + expect(result.priorYear).toEqual({ pct: 50, pairs: 2 }); + }); + + it("returns null for prior windows when no readings fall in them", () => { + const sys = [reading("2026-05-01T08:00:00Z", 117)]; // only last-30d + const dia = [reading("2026-05-01T08:00:00Z", 75)]; + const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW); + expect(result.priorMonth).toBeNull(); + expect(result.priorYear).toBeNull(); + }); +}); diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index df79960a8..134c50b8f 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -221,9 +221,25 @@ export function computeBpInTargetWindows( last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; allTime: { pct: number; pairs: number } | null; + /** + * v1.4.22 W5 reconcile (Code-H2) — period-aligned 30-day window + * shifted back by one month (now-60d … now-30d). Used by the + * BD-Zielbereich tile's comparison-overlay caption when the user's + * `comparisonBaseline === "lastMonth"` so the rendered "Δ X% vs. + * last month" is honest. Null when the prior window has no pairs. + */ + priorMonth: { pct: number; pairs: number } | null; + /** + * v1.4.22 W5 reconcile (Code-H2) — same shape, shifted back one + * year (now-395d … now-365d) for `comparisonBaseline === "lastYear"`. + */ + priorYear: { pct: number; pairs: number } | null; } { const sevenDaysAgoMs = now.getTime() - 7 * DAY_MS; const thirtyDaysAgoMs = now.getTime() - 30 * DAY_MS; + const sixtyDaysAgoMs = now.getTime() - 60 * DAY_MS; + const oneYearMinus30DaysAgoMs = now.getTime() - 365 * DAY_MS; + const oneYearAgoMs = now.getTime() - 395 * DAY_MS; const sysLast7 = sysSeries.filter( (r) => r.measuredAt.getTime() >= sevenDaysAgoMs, @@ -238,6 +254,31 @@ export function computeBpInTargetWindows( (r) => r.measuredAt.getTime() >= thirtyDaysAgoMs, ); + // Period-aligned prior windows. Same 30-day arc, shifted by 30 / 365 + // days respectively. Bounds are inclusive on the lower end and + // exclusive on the upper to avoid double-counting the boundary + // reading in both windows. + const sysPriorMonth = sysSeries.filter( + (r) => + r.measuredAt.getTime() >= sixtyDaysAgoMs && + r.measuredAt.getTime() < thirtyDaysAgoMs, + ); + const diaPriorMonth = diaSeries.filter( + (r) => + r.measuredAt.getTime() >= sixtyDaysAgoMs && + r.measuredAt.getTime() < thirtyDaysAgoMs, + ); + const sysPriorYear = sysSeries.filter( + (r) => + r.measuredAt.getTime() >= oneYearAgoMs && + r.measuredAt.getTime() < oneYearMinus30DaysAgoMs, + ); + const diaPriorYear = diaSeries.filter( + (r) => + r.measuredAt.getTime() >= oneYearAgoMs && + r.measuredAt.getTime() < oneYearMinus30DaysAgoMs, + ); + return { last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets), last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets), @@ -245,5 +286,7 @@ export function computeBpInTargetWindows( // analytics route now routes the dashboard tile's headline through // this so it stops mirroring `last30Days`. allTime: computeBpInTargetPct(sysSeries, diaSeries, targets), + priorMonth: computeBpInTargetPct(sysPriorMonth, diaPriorMonth, targets), + priorYear: computeBpInTargetPct(sysPriorYear, diaPriorYear, targets), }; }