mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(blood-pressure): pin the in-target headline and BP pillar to the 90-day window
The BD-Zielbereich headline, the Health-Score BP pillar and the coach number now all read the same trailing-90-day in-target window. Re-derive the integration expectations from the fixtures: the analytics headline is the independent 90-day figure (distinct from the 30-day and all-time windows), and the BP pillar grades from the 90-day window when readings predate the trailing month rather than dropping out.
This commit is contained in:
1 parent
e1cee6086d
commit
5e36664f7d
2 files changed
+59
-43
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user