Files
HealthLog/tests/integration/pulse-hourly-mean-day.test.ts
T
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

279 lines
9.6 KiB
TypeScript

/**
* A day of pulse is the mean of its hours' means, and a window of days weighs
* each day once.
*
* A watch samples about every 5 to 10 minutes at rest and about every 5 seconds
* in a workout, so a plain mean over a day's readings counts the workout hour
* hundreds of times and the other twenty-three hours a few dozen times each.
* The worked example here is the one from the issue: a workout hour of 700
* readings at 150 bpm and 23 resting hours of 10 readings at 60 bpm.
*
* mean of the readings (700 x 150 + 230 x 60) / 930 = 127.7
* mean of the hours' means (150 + 23 x 60) / 24 = 63.75
*
* Asserted against a real Postgres, in every place a daily pulse value or a
* window of days is made: the live per-day read the chart serves, the
* rollup's DAY bucket and the weeks folded from it, and the series route's
* day bucket. HRV is seeded with the very same readings as a control: a type
* without an activity-dependent sampling rate keeps the plain mean.
*/
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 { readLiveBuckets } from "@/lib/measurements/daily-series-read";
import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read";
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),
})),
};
});
const prisma = getPrismaClient();
const USER = "pulse-hourly-mean";
const DAY = 86_400_000;
/** Monday of the ISO week two weeks back, 00:00 UTC. */
const now = new Date();
const dow = (now.getUTCDay() + 6) % 7;
const MONDAY = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() - dow - 14,
);
/** Tuesday: the workout day. Wednesday: an ordinary day. Same ISO week. */
const WORKOUT_DAY = MONDAY + DAY;
const REST_DAY = MONDAY + 2 * DAY;
const WORKOUT_HOUR = 10;
const WORKOUT_READINGS = 700;
const RESTING_READINGS_PER_HOUR = 10;
function dayRows(
type: "PULSE" | "HEART_RATE_VARIABILITY",
unit: string,
dayStart: number,
workout: boolean,
): Prisma.MeasurementCreateManyInput[] {
const rows: Prisma.MeasurementCreateManyInput[] = [];
for (let h = 0; h < 24; h += 1) {
const isWorkoutHour = workout && h === WORKOUT_HOUR;
const n = isWorkoutHour ? WORKOUT_READINGS : RESTING_READINGS_PER_HOUR;
for (let i = 0; i < n; i += 1) {
// Inside the hour, at least 1 s apart; the workout hour is dense.
const offsetMs = isWorkoutHour ? i * 5_000 : (5 + i * 4) * 60_000;
rows.push({
id: `${type}-${dayStart}-${h}-${i}`,
userId: USER,
type,
unit,
source: "APPLE_HEALTH",
value: isWorkoutHour ? 150 : 60,
measuredAt: new Date(dayStart + h * 3_600_000 + offsetMs),
});
}
}
return rows;
}
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, timezone: "UTC" },
});
invalidateUserTimezone(USER);
for (const [type, unit] of [
["PULSE", "bpm"],
["HEART_RATE_VARIABILITY", "ms"],
] as const) {
await prisma.measurement.createMany({
data: [
...dayRows(type, unit, WORKOUT_DAY, true),
...dayRows(type, unit, REST_DAY, false),
],
});
}
await recomputeUserRollups(USER, {
types: ["PULSE", "HEART_RATE_VARIABILITY"],
from: new Date(MONDAY),
to: new Date(MONDAY + 7 * DAY),
});
}, 120_000);
const SAMPLE_MEAN_WORKOUT_DAY =
(WORKOUT_READINGS * 150 + 23 * RESTING_READINGS_PER_HOUR * 60) /
(WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR);
const HOURLY_MEAN_WORKOUT_DAY = (150 + 23 * 60) / 24;
describe("the worked example", () => {
it("really is two different numbers", () => {
expect(SAMPLE_MEAN_WORKOUT_DAY).toBeCloseTo(127.74, 2);
expect(HOURLY_MEAN_WORKOUT_DAY).toBeCloseTo(63.75, 5);
});
});
describe("the live per-day read the chart serves", () => {
const read = (
type: "PULSE" | "HEART_RATE_VARIABILITY",
grain: "daily" | "weekly",
) =>
readLiveBuckets({
userId: USER,
type,
from: new Date(MONDAY),
to: new Date(MONDAY + 7 * DAY),
cap: 400,
priorityJson: null,
grain,
timeZone: "UTC",
});
it("gives a workout day the mean of its hours, not of its readings", async () => {
const rows = await read("PULSE", "daily");
const workout = rows.find(
(r) => new Date(r.measuredAt).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 6);
// The reading count and the band stay over every reading.
expect(workout?.count).toBe(
WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR,
);
expect(workout?.minValue).toBe(60);
expect(workout?.maxValue).toBe(150);
const rest = rows.find(
(r) => new Date(r.measuredAt).getTime() === REST_DAY,
);
expect(rest?.value).toBeCloseTo(60, 6);
});
it("weighs each day once when it folds days into a week", async () => {
const [week] = await read("PULSE", "weekly");
// Mean of the two days' means, not of their readings (which would be ~114).
expect(week.value).toBeCloseTo((HOURLY_MEAN_WORKOUT_DAY + 60) / 2, 6);
expect(week.count).toBe(
WORKOUT_READINGS +
23 * RESTING_READINGS_PER_HOUR +
24 * RESTING_READINGS_PER_HOUR,
);
});
it("keeps the plain mean for a type with no activity-dependent sampling (HRV)", async () => {
const rows = await read("HEART_RATE_VARIABILITY", "daily");
const workout = rows.find(
(r) => new Date(r.measuredAt).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 6);
const [week] = await read("HEART_RATE_VARIABILITY", "weekly");
const total =
WORKOUT_READINGS * 150 +
23 * RESTING_READINGS_PER_HOUR * 60 +
24 * RESTING_READINGS_PER_HOUR * 60;
const readings = WORKOUT_READINGS + 47 * RESTING_READINGS_PER_HOUR;
expect(week.value).toBeCloseTo(total / readings, 6);
});
});
describe("the rollup tier", () => {
const rollup = (
type: "PULSE" | "HEART_RATE_VARIABILITY",
granularity: "DAY" | "WEEK",
) =>
readCanonicalRollupBuckets({
userId: USER,
type,
granularity,
from: new Date(MONDAY),
to: new Date(MONDAY + 7 * DAY),
toInclusive: true,
});
it("stores the mean of the hours' means as the DAY mean, and nothing else changes", async () => {
const days = await rollup("PULSE", "DAY");
const workout = days.find((d) => d.bucketStart.getTime() === WORKOUT_DAY)!;
expect(workout.mean).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 6);
expect(workout.count).toBe(
WORKOUT_READINGS + 23 * RESTING_READINGS_PER_HOUR,
);
expect(workout.minValue).toBe(60);
expect(workout.maxValue).toBe(150);
// The sums the regression composes from stay over every reading.
expect(workout.sumValue).toBe(
WORKOUT_READINGS * 150 + 23 * RESTING_READINGS_PER_HOUR * 60,
);
});
it("gives a week the mean of its days' means", async () => {
const [week] = await rollup("PULSE", "WEEK");
expect(week.days).toBe(2);
expect(week.mean).toBeCloseTo((HOURLY_MEAN_WORKOUT_DAY + 60) / 2, 6);
expect(week.dayMean).toBeCloseTo(week.mean, 9);
});
it("keeps the plain mean for HRV, DAY and WEEK alike", async () => {
const days = await rollup("HEART_RATE_VARIABILITY", "DAY");
const workout = days.find((d) => d.bucketStart.getTime() === WORKOUT_DAY)!;
expect(workout.mean).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 6);
const [week] = await rollup("HEART_RATE_VARIABILITY", "WEEK");
const total = WORKOUT_READINGS * 150 + 47 * RESTING_READINGS_PER_HOUR * 60;
expect(week.mean).toBeCloseTo(
total / (WORKOUT_READINGS + 47 * RESTING_READINGS_PER_HOUR),
6,
);
});
});
describe("the series route's day bucket", () => {
async function series(kind: string) {
const s = await prisma.session.create({
data: { userId: USER, 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=365`,
),
);
expect(res.status).toBe(200);
return (
(await res.json()) as {
data: { points: Array<{ id: string; at: string; value: number }> };
}
).data.points;
}
it("is the mean of the hours' means for pulse", async () => {
const points = await series("pulse");
expect(points.every((p) => p.id.startsWith("day:"))).toBe(true);
const workout = points.find(
(p) => new Date(p.at).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(HOURLY_MEAN_WORKOUT_DAY, 2);
});
it("is the plain mean for HRV", async () => {
const points = await series("heartRateVariability");
const workout = points.find(
(p) => new Date(p.at).getTime() === WORKOUT_DAY,
);
expect(workout?.value).toBeCloseTo(SAMPLE_MEAN_WORKOUT_DAY, 2);
});
});