diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts index cfaec655b..4557ddf3d 100644 --- a/tests/integration/analytics-health-score.test.ts +++ b/tests/integration/analytics-health-score.test.ts @@ -195,22 +195,26 @@ describe("GET /api/analytics — Health Score", () => { expect(hs.components.compliance.value).toBeGreaterThanOrEqual(95); }); - it("scores the BP pillar from all-time history even with no readings in the trailing 30 days", async () => { - // Regression pin: the BD-Zielbereich tile headline reads the trailing - // 30 days, but the Health-Score BP pillar must read the all-time - // window. A prior change fed the 30-day value into the score, so an - // account whose BP readings predate the trailing month lost the - // 0.30-weight BP pillar entirely and rendered "no rating" despite - // having BP data. This pins the all-time feed. + it("scores the BP pillar from the trailing-90-day window when readings predate the last 30 days", async () => { + // v1.17 W1d regression pin: the BD-Zielbereich tile headline, the + // Health-Score BP pillar and the coach number all read the SAME + // trailing-90-day window. A prior shape fed the 30-day value into the + // score, so an account whose BP readings predate the trailing month + // lost the 0.30-weight BP pillar entirely and rendered "no rating" + // despite having recent-enough BP data. This pins the 90-day feed: + // readings 60–80 days ago sit OUTSIDE the trailing 30 days but INSIDE + // the 90-day window, so both the pillar and the headline must surface + // them. const prisma = getPrismaClient(); const user = await seedSession("hs-bp-old"); const now = Date.now(); const DAY = 24 * 60 * 60 * 1000; - // 20 paired in-target readings, all ~60–80 days ago (outside the - // trailing-30-day window the tile headline uses, inside the all-time - // window the score should use). + // 20 paired in-target readings, all ~60–80 days ago: outside the + // trailing-30-day window, inside the trailing-90-day window the score + // and headline now share. Well above the 5-reading confidence floor, + // so the pillar grades rather than suppressing as thin data. for (let i = 0; i < 20; i++) { const at = new Date(now - (60 + i) * DAY); await prisma.measurement.create({ @@ -243,19 +247,21 @@ describe("GET /api/analytics — Health Score", () => { }; expect(env.data!.healthScore).not.toBeNull(); const hs = env.data!.healthScore!; - // The BP pillar must score from the all-time window (recency-weighted)… + // The BP pillar scores from the 90-day window (recency-weighted). // 122/78 sits just inside the under-65 target ceiling (129/79), so the - // graded clinical-proximity score lands high (worst-of-axis ≈ 86) rather - // than the old binary 100. The point of this test is that the pillar still - // SCORES from all-time history when the trailing-30-day window is empty — - // a clearly in-target history must read as a strong (≥ 80) pillar, not drop + // graded clinical-proximity score lands high (worst-of-axis ≈ 86) + // rather than the old binary 100. The point: the pillar still SCORES + // from history when the trailing-30-day window is empty — a clearly + // in-target 90-day history reads as a strong (≥ 80) pillar, not drop // out entirely (the regression this test pins). expect(hs.components.bp.value).not.toBeNull(); expect(hs.components.bp.value).toBeGreaterThanOrEqual(80); expect(hs.components.bp.weight).toBeGreaterThan(0); - // …while the tile headline stays scoped to the trailing 30 days - // (no readings there → null), proving the two are decoupled. - expect(env.data!.bpInTargetPct).toBeNull(); + // …and the tile headline now reads the SAME 90-day window, so it + // surfaces the in-target rate (all 20 pairs in target = 100 %) rather + // than dropping to null. The pillar and headline are unified on the + // window — that is the v1.17 W1d contract. + expect(env.data!.bpInTargetPct).toBe(100); }); it("returns null healthScore for a user with no data", async () => { diff --git a/tests/integration/bp-in-target.test.ts b/tests/integration/bp-in-target.test.ts index 19ce8b601..f76f42a39 100644 --- a/tests/integration/bp-in-target.test.ts +++ b/tests/integration/bp-in-target.test.ts @@ -529,23 +529,24 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape }); /** - * v1.4.22 A1 — re-anchor the headline to last-30 days. + * v1.17 W1d — re-anchor the headline to the trailing 90 days. * - * Up to v1.4.19 the headline was the all-time figure. That made the - * tile correct (no algorithmic 50/50/50 pin) but emotionally wrong: - * the headline was the slowest-moving aggregate possible, so a user - * who had genuinely improved their last-30-day discipline saw a - * stubbornly low number that took years of further compliance to - * budge. v1.4.22 routes the headline through `last30Days` and surfaces - * `7d` / `30d` / `Allzeit` as a 3-line sub-row instead. + * Up to v1.4.19 the headline was the all-time figure (slowest-moving + * aggregate possible); v1.4.22 swung it to the trailing 30 days (recency + * but a thin denominator that whipsaws on a single reading). v1.17 W1d + * standardises the headline, the Health-Score BP pillar and the coach + * grounding number all on the SAME trailing-90-day window — long enough + * to be stable, recent enough to move. The route routes `bpInTargetPct` + * through `windows.last90Days` and keeps `7d` / `30d` / `Allzeit` as the + * sub-rows. * - * The integration assertion: with three legitimately divergent - * windows (recent 50 % vs all-time 13 %) the route's `bpInTargetPct` - * field equals `windows.last30Days.pct` (50 %), and a separate - * `bpInTargetPctAllTime` carries the long-arc number (13 %) so the - * tile can render the third sub-line. + * The integration assertion: with divergent windows the headline equals + * neither the 30-day window (50 %) nor the all-time aggregate (13 %) — + * it is the genuinely independent 90-day number — and the separate + * `bpInTargetPctAllTime` still carries the long-arc figure for the tile's + * sub-line. */ -describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { +describe("GET /api/analytics — BP-in-target headline (v1.17 W1d)", () => { async function seedSession(username: string) { const prisma = getPrismaClient(); const user = await prisma.user.create({ @@ -566,7 +567,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { return user; } - it("routes the headline through last-30-days and exposes all-time as a separate field", async () => { + it("routes the headline through the trailing 90 days and exposes all-time as a separate field", async () => { const prisma = getPrismaClient(); const user = await seedSession("bp-headline-fixture"); @@ -574,7 +575,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { const DAY = 24 * 60 * 60 * 1000; // Seed exactly the v1.4.19-A1 production-shape regression: 7d and - // 30d both ~50 %, all-time ~13 %. + // 30d both ~50 %, all-time ~13 %, with a third distinct 90-day figure. const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // Last 7 days: 1 IN, 1 OUT. { daysAgo: 1.5, sys: 118, dia: 78 }, @@ -633,11 +634,15 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { expect(env.data).not.toBeNull(); const data = env.data!; - // The headline now equals the 30-day window — not the all-time aggregate. - // 30-day = 5/10 in target = 50 %. - expect(data.bpInTargetPct).toBe(50); + // The headline now equals the trailing-90-day window. In-window pairs: + // 2 (last 7 days) + 8 (7–30 days) + the older OUT readings whose + // `daysAgo = 60 + i*3 < 90` (i = 0..9, the reading at exactly 90 d + // sits on the exclusive boundary) = 20 paired readings, of which 5 + // are in target (the recent 5 IN). 5/20 = 25 %. + expect(data.bpInTargetPct).toBe(Math.round((5 / 20) * 100)); + + // 30-day window stays its own number: 5/10 in target = 50 %. expect(data.bpInTargetPct30d).toBe(50); - expect(data.bpInTargetPct).toBe(data.bpInTargetPct30d); // 7-day window: 2 paired readings, 1 in target = 50 %. expect(data.bpInTargetPct7d).toBe(50); @@ -646,9 +651,10 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { // separate field so the tile can render it as a sub-row. expect(data.bpInTargetPctAllTime).toBe(Math.round((5 / 40) * 100)); - // Smoking gun: the headline must NOT equal the all-time number now - // that the route re-anchored to last-30. This is the v1.4.22 A1 - // contract pin. + // Smoking gun: the headline is now the independent 90-day window — it + // must equal neither the 30-day window nor the all-time aggregate. + // This is the v1.17 W1d contract pin. + expect(data.bpInTargetPct).not.toBe(data.bpInTargetPct30d); expect(data.bpInTargetPct).not.toBe(data.bpInTargetPctAllTime); }); @@ -730,9 +736,13 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { }; }; const data = env.data; - // 30-day headline: 15 of 30 in target = 50 %. + // 30-day window: 15 of 30 in target = 50 %. expect(data.bpInTargetPct30d).toBe(50); - expect(data.bpInTargetPct).toBe(50); + // 90-day headline (v1.17 W1d): the 30 recent pairs (15 IN) plus the + // older out-of-target rows whose `daysAgo = 40 + i < 90` (i = 0..49, + // 50 rows; the reading at exactly 90 d sits on the exclusive boundary) + // = 80 paired readings, 15 in target. 15/80 = 19 % (rounded). + expect(data.bpInTargetPct).toBe(Math.round((15 / 80) * 100)); // All-time aggregate diluted by 2970 out-of-target older rows ≈ 1 %. expect(data.bpInTargetPctAllTime).toBeLessThan(5); }, 30_000);