mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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.
173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
/**
|
|
* Shared cases for the request-clock suites: the summary windows count from
|
|
* the clock of the request, never from the database's.
|
|
*
|
|
* The summaries slice and the comprehensive aggregator cut some windows in JS
|
|
* and the rest in SQL. The SQL side read `NOW()`, so with the process clock
|
|
* pinned (as every parity walk does) the 7-, 30- and 60-day means and the
|
|
* 90-day canonical pick were cut hours or days away from the JS windows, and
|
|
* the walks passed or failed with the hour they ran at.
|
|
*
|
|
* Each case pins the process clock to a day long before the database's own
|
|
* clock, at four times of day, and lays readings ten minutes either side of
|
|
* every window edge. Counted from the pinned clock the figures are exact;
|
|
* counted from the database clock every window would be empty. Both read
|
|
* paths run: the live aggregate before any rollup exists, the rollup path
|
|
* after the DAY tier is built. The streak's local days ride along: their
|
|
* bounds are instants too, and a session outside UTC moved them.
|
|
*
|
|
* Not a test file. Only for tests under `tests/integration/`.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
computeSummariesSlice,
|
|
readSevenDayMeans,
|
|
} from "@/lib/analytics/summaries-slice";
|
|
import { readActivityDayKeys } from "@/lib/dashboard/summary-reads";
|
|
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
|
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
|
|
import {
|
|
isFullyCovered,
|
|
probeRollupCoverage,
|
|
} from "@/lib/rollups/measurement-coverage";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
|
|
import { makeUser } from "../kit/factories";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const DAY = 86_400_000;
|
|
const MINUTE = 60_000;
|
|
|
|
/** A day far from any database clock the suite will meet. */
|
|
const PINNED_DAY = Date.UTC(2025, 2, 12);
|
|
const TIMES_OF_DAY: Array<[string, number]> = [
|
|
["00:30", 30 * MINUTE],
|
|
["09:00", 9 * 60 * MINUTE],
|
|
["12:00", 12 * 60 * MINUTE],
|
|
["23:30", (23 * 60 + 30) * MINUTE],
|
|
];
|
|
|
|
/**
|
|
* Weight readings (one source, so the canonical pick keeps them all) placed
|
|
* ten minutes inside or outside an edge, as offsets from `now`.
|
|
*/
|
|
const READINGS: Array<[offsetMs: number, value: number]> = [
|
|
[-60 * MINUTE, 80],
|
|
[-7 * DAY + 10 * MINUTE, 80], // inside the 7-day window
|
|
[-7 * DAY - 10 * MINUTE, 100], // outside it, inside the 30-day one
|
|
[-30 * DAY - 10 * MINUTE, 90], // the month before
|
|
[-60 * DAY + 10 * MINUTE, 90], // the month before, at its start
|
|
[-60 * DAY - 10 * MINUTE, 200], // older than the month before
|
|
[-90 * DAY + 10 * MINUTE, 70], // inside the 90-day window
|
|
[-90 * DAY - 10 * MINUTE, 300], // outside it
|
|
];
|
|
|
|
/** The figures the readings give when every window counts from `now`. */
|
|
const EXPECTED = {
|
|
avg7: 80,
|
|
avg30: Math.round(((80 + 80 + 100) / 3) * 100) / 100,
|
|
avg30LastMonth: 90,
|
|
/** Median of 70, 80, 80, 90, 90, 100, 200. */
|
|
median: 90,
|
|
/** Readings inside the 90-day window. */
|
|
inNinetyDays: 7,
|
|
};
|
|
|
|
export function defineRequestClockCases(expectedZone: string): void {
|
|
const prisma = getPrismaClient();
|
|
|
|
afterAll(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it(`runs in the ${expectedZone} session zone`, async () => {
|
|
const [{ zone }] = await prisma.$queryRaw<Array<{ zone: string }>>`
|
|
SELECT current_setting('TimeZone') AS zone
|
|
`;
|
|
expect(zone).toBe(expectedZone);
|
|
});
|
|
|
|
describe.each(TIMES_OF_DAY)("with the clock at %s UTC", (_label, time) => {
|
|
const now = new Date(PINNED_DAY + time);
|
|
let userId = "";
|
|
|
|
beforeAll(async () => {
|
|
vi.useFakeTimers({ toFake: ["Date"], now });
|
|
await truncateAllTables(prisma);
|
|
const user = await makeUser({ timezone: "UTC" });
|
|
userId = user.id;
|
|
await prisma.measurement.createMany({
|
|
data: READINGS.map(([offset, value]) => {
|
|
const measuredAt = new Date(now.getTime() + offset);
|
|
return {
|
|
userId,
|
|
type: "WEIGHT" as const,
|
|
value,
|
|
unit: "kg",
|
|
measuredAt,
|
|
createdAt: measuredAt,
|
|
};
|
|
}),
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await settleBackgroundTasks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function expectWindows(path: "live" | "rollup"): Promise<void> {
|
|
expect(isFullyCovered(await probeRollupCoverage(userId))).toBe(
|
|
path === "rollup",
|
|
);
|
|
|
|
const slice = await computeSummariesSlice(userId);
|
|
await settleBackgroundTasks();
|
|
const weight = slice.summaries.WEIGHT;
|
|
expect(weight.avg7).toBe(EXPECTED.avg7);
|
|
expect(weight.avg30).toBe(EXPECTED.avg30);
|
|
expect(weight.avg30LastMonth).toBe(EXPECTED.avg30LastMonth);
|
|
expect(weight.median).toBe(EXPECTED.median);
|
|
// The staleness of the latest reading counts from the same clock.
|
|
expect(slice.lastSeenByType.WEIGHT?.daysAgo).toBe(0);
|
|
|
|
const aggregate = await buildComprehensiveAggregate(userId);
|
|
await settleBackgroundTasks();
|
|
const summary = aggregate.summaries.WEIGHT;
|
|
expect(summary?.avg7).toBe(EXPECTED.avg7);
|
|
expect(summary?.avg30).toBe(EXPECTED.avg30);
|
|
expect(summary?.avg30LastMonth).toBe(EXPECTED.avg30LastMonth);
|
|
expect(aggregate.weightRawRows).toHaveLength(EXPECTED.inNinetyDays);
|
|
|
|
expect((await readSevenDayMeans(userId)).WEIGHT).toBe(EXPECTED.avg7);
|
|
}
|
|
|
|
it("keys the streak's local days on the request's clock", async () => {
|
|
const keys = await readActivityDayKeys(
|
|
prisma,
|
|
userId,
|
|
"UTC",
|
|
new Date(now.getTime() - 3 * DAY),
|
|
now,
|
|
);
|
|
expect(keys).toEqual([
|
|
userDayKey(new Date(now.getTime() - 60 * MINUTE), "UTC"),
|
|
]);
|
|
});
|
|
|
|
it("cuts every window of the live aggregate on the request's clock", async () => {
|
|
await expectWindows("live");
|
|
});
|
|
|
|
it("cuts every window of the rollup path on the request's clock", async () => {
|
|
const rollups = await vi.importActual<
|
|
typeof import("@/lib/rollups/measurement-rollups")
|
|
>("@/lib/rollups/measurement-rollups");
|
|
await rollups.ensureUserRollupsFresh(userId);
|
|
await settleBackgroundTasks();
|
|
await expectWindows("rollup");
|
|
});
|
|
});
|
|
}
|