mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(analytics): BP target headline reads last-30d, all-time becomes sub-row
Up to v1.4.19 the BD-Zielbereich tile pinned 50/50/50 because the route routed the headline through `windows.last30Days?.pct` — making the headline a literal copy of the `30T` sub-value. v1.4.19 A1 re-anchored the headline to all-time so the three windows could diverge. That fixed the algorithmic pin but introduced an emotional problem: the headline became the slowest-moving aggregate possible, punishing users who had recently dragged their compliance up. A user seeing 7d 50 % / 30d 50 % alongside a stubborn 11 % all-time read it as "your work doesn't count yet." Re-anchor the headline to `windows.last30Days?.pct` and surface all-time as a third sub-value (`bpInTargetPctAllTime`) so power users still see the long-arc number without it dominating. The integration test pins the new contract: with three legitimately divergent windows the route's `bpInTargetPct` equals `last30Days.pct`, and the new all-time field carries the historical figure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e794f57bae
commit
8606767058
2 files changed
+226
-12
No files matched your search
@@ -69,19 +69,20 @@ export const GET = apiHandler(async () => {
|
|||||||
let bpInTargetPct: number | null = null;
|
let bpInTargetPct: number | null = null;
|
||||||
let bpInTargetPct7d: number | null = null;
|
let bpInTargetPct7d: number | null = null;
|
||||||
let bpInTargetPct30d: number | null = null;
|
let bpInTargetPct30d: number | null = null;
|
||||||
|
let bpInTargetPctAllTime: number | null = null;
|
||||||
const bpTargets = getBpTargets(user.dateOfBirth);
|
const bpTargets = getBpTargets(user.dateOfBirth);
|
||||||
if (bpTargets) {
|
if (bpTargets) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// v1.4.19 A1 — fetch ALL paired BP rows, not just the trailing 30
|
// v1.4.22 A1 — re-anchor the BD-Zielbereich tile headline to the
|
||||||
// days. Up to v1.4.18 we filtered to the last 30 days at the DB
|
// last-30-day window. Up to v1.4.19 the headline pinned to the
|
||||||
// level and the headline (`bpInTargetPct`) was routed through
|
// 30-day average (so 7d / 30d / total all read 50 %). v1.4.19 A1
|
||||||
// `windows.last30Days?.pct` — making the headline a literal copy
|
// flipped the headline to all-time, which made the tile correct
|
||||||
// of the `30T` sub-value. On a busy account (e.g. 572 paired
|
// but emotionally wrong: the headline was the slowest-moving
|
||||||
// readings with recent 30d = 50 %, all-time ≈ 11 %) the tile
|
// aggregate possible, punishing recent improvement. v1.4.22 A1
|
||||||
// pinned 50/50/50 and looked algorithmically broken. The windowed
|
// re-routes the headline to last-30-days and surfaces 7d / 30d /
|
||||||
// helper now also returns an independent `allTime` aggregate,
|
// all-time as a 3-line sub-row so power users still see the
|
||||||
// which we surface as the headline so the three numbers can
|
// long-arc number without it dominating. The helper still returns
|
||||||
// diverge naturally.
|
// every window — only the headline pick changed.
|
||||||
const [sysData, diaData] = await Promise.all([
|
const [sysData, diaData] = await Promise.all([
|
||||||
prisma.measurement.findMany({
|
prisma.measurement.findMany({
|
||||||
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
|
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
|
||||||
@@ -94,9 +95,10 @@ export const GET = apiHandler(async () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
|
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
|
||||||
bpInTargetPct = windows.allTime?.pct ?? null;
|
bpInTargetPct = windows.last30Days?.pct ?? null;
|
||||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||||
|
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-context glucose summaries (canonical mg/dL).
|
// Per-context glucose summaries (canonical mg/dL).
|
||||||
@@ -150,6 +152,7 @@ export const GET = apiHandler(async () => {
|
|||||||
bpInTargetPct,
|
bpInTargetPct,
|
||||||
bpInTargetPct7d,
|
bpInTargetPct7d,
|
||||||
bpInTargetPct30d,
|
bpInTargetPct30d,
|
||||||
|
bpInTargetPctAllTime,
|
||||||
glucoseByContext,
|
glucoseByContext,
|
||||||
correlations,
|
correlations,
|
||||||
healthScore,
|
healthScore,
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
* Also covers the null-tolerance regression (NaN / out-of-floor) so
|
* Also covers the null-tolerance regression (NaN / out-of-floor) so
|
||||||
* the tile never crashes silently to 0 % from edge-case input.
|
* the tile never crashes silently to 0 % from edge-case input.
|
||||||
*/
|
*/
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
process.env.ENCRYPTION_KEY ??=
|
||||||
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeBpInTargetPct,
|
computeBpInTargetPct,
|
||||||
@@ -28,10 +31,42 @@ import {
|
|||||||
} from "@/lib/analytics/bp-in-target";
|
} from "@/lib/analytics/bp-in-target";
|
||||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||||
|
|
||||||
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||||
|
|
||||||
|
vi.mock("next/headers", async () => {
|
||||||
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||||
|
return {
|
||||||
|
headers: vi.fn(async () => ({
|
||||||
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||||
|
})),
|
||||||
|
cookies: vi.fn(async () => ({
|
||||||
|
get: (name: string) => {
|
||||||
|
const value = cookieJar.get(name);
|
||||||
|
return value ? { name, value } : undefined;
|
||||||
|
},
|
||||||
|
set: (name: string, value: string) => {
|
||||||
|
cookieJar.set(name, value);
|
||||||
|
},
|
||||||
|
delete: (name: string) => {
|
||||||
|
cookieJar.delete(name);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/db-compat", () => ({
|
||||||
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await truncateAllTables(getPrismaClient());
|
await truncateAllTables(getPrismaClient());
|
||||||
|
cookieJar.clear();
|
||||||
|
headerJar.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("BP-in-target % — production data shape", () => {
|
describe("BP-in-target % — production data shape", () => {
|
||||||
@@ -491,3 +526,179 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
|||||||
expect(windows.last30Days).toEqual({ pct: 100, pairs: 1 });
|
expect(windows.last30Days).toEqual({ pct: 100, pairs: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.22 A1 — re-anchor the headline to last-30 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => {
|
||||||
|
async function seedSession(username: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
email: `${username}@example.test`,
|
||||||
|
role: "USER",
|
||||||
|
dateOfBirth: new Date("1985-07-09"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const session = await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cookieJar.set("healthlog_session", session.id);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("routes the headline through last-30-days and exposes all-time as a separate field", async () => {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const user = await seedSession("bp-headline-fixture");
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Seed exactly the v1.4.19-A1 production-shape regression: 7d and
|
||||||
|
// 30d both ~50 %, all-time ~13 %.
|
||||||
|
const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [
|
||||||
|
// Last 7 days: 1 IN, 1 OUT.
|
||||||
|
{ daysAgo: 1.5, sys: 117, dia: 79 },
|
||||||
|
{ daysAgo: 5.5, sys: 145, dia: 95 },
|
||||||
|
// 7-30 days ago: 4 IN, 4 OUT.
|
||||||
|
{ daysAgo: 8, sys: 122, dia: 75 },
|
||||||
|
{ daysAgo: 10, sys: 145, dia: 95 },
|
||||||
|
{ daysAgo: 12, sys: 125, dia: 78 },
|
||||||
|
{ daysAgo: 14, sys: 145, dia: 90 },
|
||||||
|
{ daysAgo: 18, sys: 120, dia: 70 },
|
||||||
|
{ daysAgo: 22, sys: 150, dia: 95 },
|
||||||
|
{ daysAgo: 25, sys: 128, dia: 79 },
|
||||||
|
{ daysAgo: 28, sys: 160, dia: 100 },
|
||||||
|
// Older history: 30 readings, all OUT (drives all-time well below 50 %).
|
||||||
|
...Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
daysAgo: 60 + i * 3,
|
||||||
|
sys: 160,
|
||||||
|
dia: 100,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
for (const r of seed) {
|
||||||
|
const at = new Date(now - r.daysAgo * DAY);
|
||||||
|
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 { GET } = await import("@/app/api/analytics/route");
|
||||||
|
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||||
|
new Request("http://localhost/api/analytics"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const env = (await res.json()) as {
|
||||||
|
data: {
|
||||||
|
bpInTargetPct: number | null;
|
||||||
|
bpInTargetPct7d: number | null;
|
||||||
|
bpInTargetPct30d: number | null;
|
||||||
|
bpInTargetPctAllTime: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// All-time = 5 / 40 in target = 13 % (rounded). Surfaced as a
|
||||||
|
// 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.
|
||||||
|
expect(data.bpInTargetPct).not.toBe(data.bpInTargetPctAllTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits all three windows in the response envelope", async () => {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const user = await seedSession("bp-three-windows-route-fixture");
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Minimum seed that produces non-null for every window.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const at = new Date(now - (i + 0.5) * DAY);
|
||||||
|
await prisma.measurement.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: "BLOOD_PRESSURE_SYS",
|
||||||
|
value: 122,
|
||||||
|
unit: "mmHg",
|
||||||
|
measuredAt: at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.measurement.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: "BLOOD_PRESSURE_DIA",
|
||||||
|
value: 75,
|
||||||
|
unit: "mmHg",
|
||||||
|
measuredAt: at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { GET } = await import("@/app/api/analytics/route");
|
||||||
|
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||||
|
new Request("http://localhost/api/analytics"),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const env = (await res.json()) as {
|
||||||
|
data: {
|
||||||
|
bpInTargetPct: number | null;
|
||||||
|
bpInTargetPct7d: number | null;
|
||||||
|
bpInTargetPct30d: number | null;
|
||||||
|
bpInTargetPctAllTime: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const data = env.data;
|
||||||
|
// All three windows present + non-null.
|
||||||
|
expect(data.bpInTargetPct).not.toBeNull();
|
||||||
|
expect(data.bpInTargetPct7d).not.toBeNull();
|
||||||
|
expect(data.bpInTargetPct30d).not.toBeNull();
|
||||||
|
expect(data.bpInTargetPctAllTime).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user