mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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.
279 lines
9.6 KiB
TypeScript
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);
|
|
});
|
|
});
|