mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The summaries slice and the comprehensive aggregator took their JS windows (the DAY-bucket bounds, the rollup slope anchors, lastSeen staleness) from the process clock and their SQL windows (avg7, avg30, the month before, the median, the 90-day canonical pick, the cold-path slope evidence) from NOW(), the database's clock. One response mixed two clocks, so a reading on a window's edge fell in or out with whichever host answered, and with the process clock pinned the analytics parity walk changed its avg7 with the hour it ran at. Each read now takes one `now` and binds it into its statements (`request-clock-sql.ts`): `$N::timestamp`, which keeps the driver's offset-free UTC string as written in any session zone. The canonical-pick helper takes its window as an instant plus an interval instead of reading NOW(); the series and calendar readers anchor it on their own `since`, the BP-in-target read on its 365-day start. The window-mean and live-slope columns the two aggregates each spelled out now come from one builder. The streak's local day series read its bounds through `::timestamptz`, which follows the session zone: outside a UTC session today's reading fell out of the streak. It reads them as UTC first. The parity walks for analytics, the dashboard summary and the comprehensive overview pin a fixed day instead of today: their records are laid out from the anchor day, so the chronotype's work and free nights and the weight-by-weekday points moved with the weekday the walk ran on. The snapshots are unchanged. A new suite pins the clock to four times of day, far from the database clock, with readings ten minutes either side of every edge, on both read paths, in a UTC and an Auckland session.
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
/**
|
|
* The dense-series read, against a real Postgres: the shipped single
|
|
* statement (`readDenseSeries`) against the two statements it replaced,
|
|
* kept below as the reference (the bucket SQL and the figures SQL are the
|
|
* same builders, so the reference differs only in reading the canonical rows
|
|
* twice instead of once).
|
|
*
|
|
* Buckets and figures must match for every shape the route asks for: day
|
|
* and hour grains; an hourly-mean type (pulse, two ranked sources), an
|
|
* unranked dense type (glucose) and a ranked non-hourly one (oxygen
|
|
* saturation); zones with a DST change and a half-hour offset; a fixture
|
|
* with gaps, tombstones and readings around local and UTC midnight.
|
|
*/
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import type {
|
|
MeasurementSource,
|
|
MeasurementType,
|
|
} from "@/generated/prisma/client";
|
|
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
|
import {
|
|
denseBucketsSql,
|
|
denseFiguresSql,
|
|
readDenseSeries,
|
|
type DenseBucketRow,
|
|
type DenseFigures,
|
|
} from "@/lib/measurements/dense-series-read";
|
|
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
|
|
|
import { makeUser } from "../kit/factories";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const prisma = getPrismaClient();
|
|
const DAY_MS = 86_400_000;
|
|
|
|
/** The two statements as the route sent them before. */
|
|
async function legacyDenseSeries(args: {
|
|
userId: string;
|
|
type: MeasurementType;
|
|
grain: "day" | "hour";
|
|
userTz: string;
|
|
since: Date;
|
|
seriesRows: string;
|
|
}) {
|
|
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
|
|
const bucketRows = await prisma.$queryRawUnsafe<DenseBucketRow[]>(
|
|
`${denseBucketsSql(hourWeighted, `${args.seriesRows} WHERE m."measured_at" >= $4`)}
|
|
ORDER BY bucket_start ASC`,
|
|
args.userId,
|
|
args.grain,
|
|
args.userTz,
|
|
args.since,
|
|
);
|
|
// The figures statement took the window start as `$2` and the zone as
|
|
// `$3`, the latter only for an hourly-mean type.
|
|
const hourly = usesHourlyMeanDay(args.type);
|
|
const [rawAgg] = await prisma.$queryRawUnsafe<DenseFigures[]>(
|
|
`WITH src AS (SELECT m.* FROM ${args.seriesRows} WHERE m."measured_at" >= $2)
|
|
${denseFiguresSql(hourly)}`,
|
|
args.userId,
|
|
args.since,
|
|
...(hourly ? [args.userTz] : []),
|
|
);
|
|
return { bucketRows, rawAgg };
|
|
}
|
|
|
|
function rng(seed: number) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a = (a + 0x6d2b79f5) >>> 0;
|
|
let t = a;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
let userId = "";
|
|
|
|
async function seed() {
|
|
userId = (await makeUser({ timezone: "Europe/Berlin" })).id;
|
|
const random = rng(7);
|
|
const now = Date.now();
|
|
const rows: Array<{
|
|
userId: string;
|
|
type: MeasurementType;
|
|
source: MeasurementSource;
|
|
value: number;
|
|
valueMin: number | null;
|
|
valueMax: number | null;
|
|
unit: string;
|
|
measuredAt: Date;
|
|
deletedAt: Date | null;
|
|
}> = [];
|
|
const plan: Array<[MeasurementType, MeasurementSource[], number]> = [
|
|
["PULSE", ["APPLE_HEALTH", "WITHINGS"], 60],
|
|
["BLOOD_GLUCOSE", ["NIGHTSCOUT", "MANUAL"], 70],
|
|
["OXYGEN_SATURATION", ["APPLE_HEALTH", "WITHINGS"], 96],
|
|
];
|
|
// 200 days: across the last two DST changes, with gaps.
|
|
for (let day = 0; day < 200; day++) {
|
|
if (day % 13 === 4) continue;
|
|
const dayStart = Math.floor((now - day * DAY_MS) / DAY_MS) * DAY_MS;
|
|
for (const [type, sources, base] of plan) {
|
|
for (const source of sources) {
|
|
if (random() < 0.3) continue;
|
|
const readings = 3 + Math.floor(random() * 12);
|
|
for (let r = 0; r < readings; r++) {
|
|
const at = dayStart + Math.floor(random() * DAY_MS);
|
|
const value = Math.round((base + random() * 30) * 10) / 10;
|
|
const banded = type === "PULSE" && random() < 0.3;
|
|
rows.push({
|
|
userId,
|
|
type,
|
|
source,
|
|
value,
|
|
valueMin: banded ? value - 10 : null,
|
|
valueMax: banded ? value + 15 : null,
|
|
unit: "x",
|
|
measuredAt: new Date(at),
|
|
deletedAt: random() < 0.05 ? new Date() : null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const seen = new Set<string>();
|
|
await prisma.measurement.createMany({
|
|
data: rows.filter((row) => {
|
|
const key = `${row.type}|${row.source}|${row.measuredAt.getTime()}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
}),
|
|
});
|
|
}
|
|
|
|
const CASES: Array<[MeasurementType, "day" | "hour", number]> = [
|
|
["PULSE", "day", 180],
|
|
["PULSE", "hour", 60],
|
|
["BLOOD_GLUCOSE", "day", 180],
|
|
["BLOOD_GLUCOSE", "hour", 30],
|
|
["OXYGEN_SATURATION", "day", 120],
|
|
];
|
|
const ZONES = ["UTC", "Europe/Berlin", "Asia/Kolkata", "America/New_York"];
|
|
|
|
function close(a: number | null | undefined, b: number | null | undefined) {
|
|
if (a == null || b == null) return expect(a ?? null).toBe(b ?? null);
|
|
expect(Math.abs(a - b)).toBeLessThanOrEqual(1e-9 * Math.max(1, Math.abs(b)));
|
|
}
|
|
|
|
describe("dense-series read parity", () => {
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await seed();
|
|
});
|
|
|
|
for (const [type, grain, days] of CASES) {
|
|
for (const userTz of ZONES) {
|
|
it(`matches the two statements: ${type}, ${grain}, ${days} d, ${userTz}`, async () => {
|
|
const since = new Date(Date.now() - days * DAY_MS);
|
|
const args = {
|
|
userId,
|
|
type,
|
|
grain,
|
|
userTz,
|
|
since,
|
|
seriesRows: seriesRowsFrom(null, type, since),
|
|
};
|
|
const [now, before] = await Promise.all([
|
|
readDenseSeries(args),
|
|
legacyDenseSeries(args),
|
|
]);
|
|
expect(before.bucketRows.length).toBeGreaterThan(10);
|
|
expect(now.bucketRows.map((r) => r.bucket_start.getTime())).toEqual(
|
|
before.bucketRows.map((r) => r.bucket_start.getTime()),
|
|
);
|
|
now.bucketRows.forEach((row, i) => {
|
|
const ref = before.bucketRows[i]!;
|
|
close(row.mean, ref.mean);
|
|
close(row.min_value, ref.min_value);
|
|
close(row.max_value, ref.max_value);
|
|
});
|
|
expect(now.rawAgg?.n).toBe(before.rawAgg?.n);
|
|
close(now.rawAgg?.mean, before.rawAgg?.mean);
|
|
close(now.rawAgg?.min, before.rawAgg?.min);
|
|
close(now.rawAgg?.max, before.rawAgg?.max);
|
|
close(now.rawAgg?.sd, before.rawAgg?.sd);
|
|
});
|
|
}
|
|
}
|
|
|
|
it("answers no figures row for an hourly-mean type without readings, as before", async () => {
|
|
const empty = await makeUser();
|
|
const since = new Date(Date.now() - 365 * DAY_MS);
|
|
const args = {
|
|
userId: empty.id,
|
|
type: "PULSE" as const,
|
|
grain: "day" as const,
|
|
userTz: "UTC",
|
|
since,
|
|
seriesRows: seriesRowsFrom(null, "PULSE", since),
|
|
};
|
|
const [now, before] = await Promise.all([
|
|
readDenseSeries(args),
|
|
legacyDenseSeries(args),
|
|
]);
|
|
expect(now).toEqual(before);
|
|
expect(now.rawAgg).toBeUndefined();
|
|
});
|
|
});
|