fix(insights): align BD-Zielbereich delta math with comparison window

The tile computed `bp30 - bpAll` regardless of which baseline the
user picked under Settings → Dashboard, while rendering a caption
keyed on `comparisonBaseline` ("vs. last month" / "vs. last year").
The math therefore lied to the user. Every other trend tile uses
`tileCompareDelta()` against period-aligned `avg30LastMonth` /
`avg30LastYear` baselines.

Add `priorMonth` (now-60d…now-30d) and `priorYear` (now-395d…
now-365d) windows to `computeBpInTargetWindows()`. Ship the pcts on
the analytics envelope as `bpInTargetPctPriorMonth` /
`bpInTargetPctPriorYear`. Route the dashboard's `bpCompareDelta` to
subtract the prior window matching the user's baseline; fall back
to null for `none`.

Three new bp-in-target unit tests (priorMonth, priorYear, null
windows) and two insights-polish guards (envelope contains the
fields, page no longer subtracts `bpAll`).

Closes Code-H2.

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 22:08:03 +02:00
1 parent 75ccc01984
commit ef597bfbbd
5 files changed
+180 -2

No files matched your search

@@ -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();
});
});
+43
View File
@@ -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),
};
}