mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The doctor report's pulse average and day points, the correlation channels, the illness correlation, the personal baseline and the Coach's tiered context each folded pulse readings into a day by plain mean. They now take the day as the mean of its local hours' means and a window as the mean of its days, in the zone each reader buckets by. The day aggregates carry the weighted sums for an hourly-mean type, so every caller reads one day value. Every other type is unchanged.
176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
/**
|
|
* The per-day fold the background readers share (`readDayAggregates`) carries
|
|
* the hourly-mean day for pulse, cut on the requested zone's own hours and
|
|
* days; the vitals baseline and the tiered context read their live pulse days
|
|
* from it.
|
|
*
|
|
* Asia/Kolkata (UTC+05:30). Day one: six readings at 150 from 04:45Z to
|
|
* 04:55Z and two at 150 at 05:05Z and 05:10Z (all local 10:15 to 10:40, one
|
|
* local hour, two UTC hours), then one reading at 60 in each of local hours
|
|
* 12, 14 and 16. Hours' means 150, 60, 60, 60, day value 82.5; the readings
|
|
* average (8 x 150 + 180) / 11 = 125.45. Day two: one reading at 60.
|
|
*
|
|
* A second workout day ten days earlier (12:45Z to 13:10Z, local 18:15 to
|
|
* 18:40, then one reading at 60 at 14:00Z, 15:00Z and 16:00Z) sits in the
|
|
* tiered context's day band, which a zone this far from UTC reads live.
|
|
*/
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
|
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
|
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
|
|
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const prisma = getPrismaClient();
|
|
const USER = "day-aggregates-pulse-hourly";
|
|
const TZ = "Asia/Kolkata";
|
|
const now = new Date();
|
|
const D = Date.UTC(
|
|
now.getUTCFullYear(),
|
|
now.getUTCMonth(),
|
|
now.getUTCDate() - 10,
|
|
);
|
|
const at = (h: number, m: number) => new Date(D + h * 3_600_000 + m * 60_000);
|
|
const dayKey = (ms: number) =>
|
|
new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(new Date(ms));
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: { id: USER, username: USER, timezone: TZ },
|
|
});
|
|
invalidateUserTimezone(USER);
|
|
const readings: Array<[Date, number]> = [
|
|
...[45, 47, 49, 51, 53, 55].map((m): [Date, number] => [at(4, m), 150]),
|
|
[at(5, 5), 150],
|
|
[at(5, 10), 150],
|
|
[at(6, 30), 60],
|
|
[at(8, 30), 60],
|
|
[at(10, 30), 60],
|
|
[at(24 + 4, 0), 60],
|
|
...[45, 47, 49, 51, 53, 55, 65, 70].map((m): [Date, number] => [
|
|
at(-10 * 24 + 12, m),
|
|
150,
|
|
]),
|
|
[at(-10 * 24 + 14, 0), 60],
|
|
[at(-10 * 24 + 15, 0), 60],
|
|
[at(-10 * 24 + 16, 0), 60],
|
|
];
|
|
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
|
for (const type of ["PULSE", "HEART_RATE_VARIABILITY"] as const) {
|
|
readings.forEach(([measuredAt, value], i) =>
|
|
rows.push({
|
|
id: `${type}-${i}`,
|
|
userId: USER,
|
|
type,
|
|
unit: type === "PULSE" ? "bpm" : "ms",
|
|
source: "APPLE_HEALTH",
|
|
value,
|
|
measuredAt,
|
|
}),
|
|
);
|
|
}
|
|
await prisma.measurement.createMany({ data: rows });
|
|
}, 120_000);
|
|
|
|
describe("readDayAggregates", () => {
|
|
it("carries the hourly-mean day for pulse, on the zone's own hours", async () => {
|
|
const days = await readDayAggregates({
|
|
userId: USER,
|
|
type: "PULSE",
|
|
since: new Date(D - 86_400_000),
|
|
timeZone: TZ,
|
|
});
|
|
expect(days.map((d) => d.day)).toEqual([
|
|
dayKey(D + 5 * 3_600_000),
|
|
dayKey(D + 28 * 3_600_000),
|
|
]);
|
|
expect(days[0]).toMatchObject({ n: 11, min: 60, max: 150 });
|
|
expect(days[0].sum).toBe(8 * 150 + 3 * 60);
|
|
expect(days[0].dayMean).toBeCloseTo(82.5, 9);
|
|
expect(days[1].dayMean).toBeCloseTo(60, 9);
|
|
});
|
|
|
|
it("splits the hours per segment", async () => {
|
|
const days = await readDayAggregates({
|
|
userId: USER,
|
|
type: "PULSE",
|
|
since: new Date(D - 86_400_000),
|
|
timeZone: TZ,
|
|
// Readings before 08:00Z (the workout and local hour 12) fall into
|
|
// segment 1, the rest of the day into segment 0.
|
|
segmentStarts: [at(8, 0)],
|
|
});
|
|
const first = days.filter((d) => d.day === dayKey(D + 5 * 3_600_000));
|
|
expect(first.map((d) => [d.segment, d.dayMean])).toEqual([
|
|
[1, expect.closeTo(105, 9)],
|
|
[0, expect.closeTo(60, 9)],
|
|
]);
|
|
});
|
|
|
|
it("leaves every other type without a day mean", async () => {
|
|
const days = await readDayAggregates({
|
|
userId: USER,
|
|
type: "HEART_RATE_VARIABILITY",
|
|
since: new Date(D - 86_400_000),
|
|
timeZone: TZ,
|
|
});
|
|
expect(days[0].dayMean ?? null).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("live pulse days of the readers built on it", () => {
|
|
it("the vitals baseline", async () => {
|
|
const { points, source } = await readDayMeanSeries(
|
|
USER,
|
|
"PULSE",
|
|
30,
|
|
now,
|
|
new Map(),
|
|
TZ,
|
|
);
|
|
expect(source).toBe("live");
|
|
expect(points.map((p) => p.mean)).toEqual([
|
|
expect.closeTo(82.5, 9),
|
|
expect.closeTo(82.5, 9),
|
|
expect.closeTo(60, 9),
|
|
]);
|
|
const hrv = await readDayMeanSeries(
|
|
USER,
|
|
"HEART_RATE_VARIABILITY",
|
|
30,
|
|
now,
|
|
new Map(),
|
|
TZ,
|
|
);
|
|
expect(hrv.points[0].mean).toBeCloseTo((8 * 150 + 3 * 60) / 11, 9);
|
|
});
|
|
|
|
it("the tiered context's recent days and its live day band", async () => {
|
|
const series = await buildTieredSeries(USER, "PULSE", {
|
|
now: now.getTime(),
|
|
tz: TZ,
|
|
skipEnsureFresh: true,
|
|
});
|
|
expect(series.recentDaily.map((p) => [p.value, p.count])).toEqual([
|
|
[82.5, 11],
|
|
[60, 1],
|
|
]);
|
|
const workout = series.dayBand.find((b) => b.count === 11);
|
|
expect(workout?.mean).toBeCloseTo(82.5, 2);
|
|
const hrv = await buildTieredSeries(USER, "HEART_RATE_VARIABILITY", {
|
|
now: now.getTime(),
|
|
tz: TZ,
|
|
skipEnsureFresh: true,
|
|
});
|
|
expect(hrv.recentDaily[0].value).toBeCloseTo((8 * 150 + 180) / 11, 2);
|
|
expect(hrv.dayBand.find((b) => b.count === 11)?.mean).toBeCloseTo(
|
|
(8 * 150 + 180) / 11,
|
|
2,
|
|
);
|
|
});
|
|
});
|