Files
HealthLog/tests/integration/pulse-window-mean-readers.test.ts
T
Marc-André Bombeck 3634f6c520 Average pulse by day in the dashboard, insights and series stats
The dashboard summaries, the insights aggregate and the series stats strip
still took a plain mean over pulse readings for their 7-day, 30-day, prior-month
and all-time figures, while the year-ago figure and the chart already weighed
each day once, so the year delta compared two different statistics. They now
go through the day-mean helper: each day is the mean of its hours' means and a
window the mean of its days, in the frame each reader buckets its days by.
Count, min, max, the median and the spread stay over every reading; every other
type is unchanged.
2026-10-04 11:22:17 +02:00

210 lines
7.6 KiB
TypeScript

/**
* The dashboard's and the insights' window means of pulse weigh each day once,
* each day the mean of its hours' means; heart-rate variability, seeded with
* the very same readings, keeps the plain mean over its readings.
*
* A workout day: twelve readings at 150 bpm inside one hour, then three
* resting hours of one reading at 60 bpm. Its day value is (150 + 3 x 60) / 4
* = 82.5, its readings' mean (12 x 150 + 3 x 60) / 15 = 132. A quiet day: one
* reading at 60.
*
* last 30 days workout day + quiet day 71.25 (plain 127.5)
* days 30 to 60 workout day + quiet day 71.25 (plain 127.5)
* six years ago workout day alone 82.5 (plain 132)
* all time five days 73.5 (plain 6060 / 47)
*
* Asserted on both paths of the dashboard summaries slice (the rollup tier,
* with the six-year-old day in the pre-fold remainder, and the live
* fallback), both paths of the insights aggregate, and the stats strip of the
* series route's raw window.
*/
import { NextRequest } from "next/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import type { Prisma } from "@/generated/prisma/client";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
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),
}));
// The reads kick off a background rollup refresh; keep it off so the live
// user stays on the live path for the whole file.
vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/rollups/measurement-rollups")>();
return { ...actual, ensureUserRollupsFresh: vi.fn(async () => undefined) };
});
const prisma = getPrismaClient();
const DAY = 86_400_000;
const now = new Date();
const T0 = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const LIVE = "pulse-window-live";
const ROLLUP = "pulse-window-rollup";
const WORKOUT_DAYS = [T0 - 3 * DAY, T0 - 45 * DAY, T0 - 6 * 365 * DAY];
const QUIET_DAYS = [T0 - 2 * DAY, T0 - 44 * DAY];
const HOURLY = {
window: (82.5 + 60) / 2,
allTime: (82.5 * 3 + 60 * 2) / 5,
};
const PLAIN = {
window: (12 * 150 + 3 * 60 + 60) / 16,
allTime: (3 * (12 * 150 + 3 * 60) + 2 * 60) / 47,
};
function rows(
userId: string,
type: "PULSE" | "HEART_RATE_VARIABILITY",
): Prisma.MeasurementCreateManyInput[] {
const unit = type === "PULSE" ? "bpm" : "ms";
const out: Prisma.MeasurementCreateManyInput[] = [];
const push = (at: number, value: number) =>
out.push({
id: `${userId}-${type}-${at}`,
userId,
type,
unit,
source: "APPLE_HEALTH",
value,
measuredAt: new Date(at),
});
for (const d of WORKOUT_DAYS) {
for (let i = 0; i < 12; i += 1) push(d + 10 * 3_600_000 + i * 300_000, 150);
for (const h of [12, 14, 16]) push(d + h * 3_600_000, 60);
}
for (const d of QUIET_DAYS) push(d + 8 * 3_600_000, 60);
return out;
}
beforeAll(async () => {
await truncateAllTables(prisma);
for (const user of [LIVE, ROLLUP]) {
await prisma.user.create({
data: { id: user, username: user, timezone: "UTC" },
});
invalidateUserTimezone(user);
await prisma.measurement.createMany({
data: [...rows(user, "PULSE"), ...rows(user, "HEART_RATE_VARIABILITY")],
});
}
// The rollup user's DAY tier covers the last 400 days; the six-year-old
// workout day stays outside it, in the pre-fold remainder.
await recomputeUserRollups(ROLLUP, {
types: ["PULSE", "HEART_RATE_VARIABILITY"],
from: new Date(T0 - 400 * DAY),
to: new Date(T0 + DAY),
});
}, 120_000);
describe.each([
{ path: "rollup", user: ROLLUP },
{ path: "live", user: LIVE },
])("dashboard summaries slice, $path path", ({ user }) => {
it("weighs each day once for pulse", async () => {
const { summaries } = await computeSummariesSlice(user);
const pulse = summaries.PULSE;
expect(pulse.avg7).toBeCloseTo(HOURLY.window, 2);
expect(pulse.avg30).toBeCloseTo(HOURLY.window, 2);
expect(pulse.avg30LastMonth).toBeCloseTo(HOURLY.window, 2);
expect(pulse.mean).toBeCloseTo(HOURLY.allTime, 2);
// Count, min and max stay over every reading.
expect(pulse.count).toBe(47);
expect(pulse.min).toBe(60);
expect(pulse.max).toBe(150);
});
it("keeps the plain mean for HRV", async () => {
const { summaries } = await computeSummariesSlice(user);
const hrv = summaries.HEART_RATE_VARIABILITY;
expect(hrv.avg7).toBeCloseTo(PLAIN.window, 2);
expect(hrv.avg30).toBeCloseTo(PLAIN.window, 2);
expect(hrv.avg30LastMonth).toBeCloseTo(PLAIN.window, 2);
expect(hrv.mean).toBeCloseTo(PLAIN.allTime, 2);
});
});
describe.each([
{ path: "rollup", user: ROLLUP },
{ path: "live", user: LIVE },
])("insights aggregate, $path path", ({ user }) => {
it("weighs each day once for pulse and keeps the plain mean for HRV", async () => {
const { summaries } = await buildComprehensiveAggregate(user);
expect(summaries.PULSE.avg7).toBeCloseTo(HOURLY.window, 2);
expect(summaries.PULSE.avg30).toBeCloseTo(HOURLY.window, 2);
expect(summaries.PULSE.avg30LastMonth).toBeCloseTo(HOURLY.window, 2);
// The 90-day window holds two workout days and two quiet days.
expect(summaries.PULSE.mean).toBeCloseTo(HOURLY.window, 2);
expect(summaries.PULSE.count).toBe(32);
const hrv = summaries.HEART_RATE_VARIABILITY;
expect(hrv.avg7).toBeCloseTo(PLAIN.window, 2);
expect(hrv.avg30LastMonth).toBeCloseTo(PLAIN.window, 2);
expect(hrv.mean).toBeCloseTo(PLAIN.window, 2);
});
});
describe("series route, raw window", () => {
async function stats(kind: string) {
const s = await prisma.session.create({
data: { userId: LIVE, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", s.id);
const { GET } = await import("@/app/api/measurements/series/route");
const res = await GET(
new NextRequest(
`http://localhost/api/measurements/series?kind=${kind}&days=30`,
),
);
expect(res.status).toBe(200);
return (
(await res.json()) as {
data: {
points: unknown[];
stats: { mean: number; count: number; min: number; max: number };
};
}
).data;
}
it("gives pulse the mean of its days on the stats strip", async () => {
const data = await stats("pulse");
// Raw points: every reading of the last 30 days.
expect(data.points).toHaveLength(16);
expect(data.stats.mean).toBeCloseTo(HOURLY.window, 2);
expect(data.stats.count).toBe(16);
expect(data.stats.min).toBe(60);
expect(data.stats.max).toBe(150);
});
it("keeps the plain mean for HRV", async () => {
const data = await stats("heartRateVariability");
expect(data.stats.mean).toBeCloseTo(PLAIN.window, 2);
});
});