diff --git a/src/lib/measurements/__tests__/day-mean.test.ts b/src/lib/measurements/__tests__/day-mean.test.ts new file mode 100644 index 000000000..f2da9e6fc --- /dev/null +++ b/src/lib/measurements/__tests__/day-mean.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +import { + dayValue, + dayValueRows, + dayWeightedRows, + foldMeanSql, + hourMeanRows, + isHourlyMeanTypeSql, + readingsMean, + windowMeanSql, + zoneDayFrame, + SESSION_DAY_FRAME, +} from "../day-mean"; + +const at = (iso: string) => new Date(iso); + +/** One hard hour of 12 readings at 150 bpm, then three resting hours of one reading at 60. */ +function workoutDay(day: string) { + const rows = Array.from({ length: 12 }, (_, i) => ({ + value: 150, + measuredAt: at(`${day}T10:${String(i * 5).padStart(2, "0")}:00Z`), + })); + for (const h of ["12", "14", "16"]) { + rows.push({ value: 60, measuredAt: at(`${day}T${h}:00:00Z`) }); + } + return rows; +} + +describe("dayValue", () => { + it("is the mean of the hours' means for pulse", () => { + expect(dayValue("PULSE", workoutDay("2026-03-02"), "UTC")).toBeCloseTo( + (150 + 60 * 3) / 4, + 10, + ); + }); + + it("is the plain mean for every other type", () => { + const rows = workoutDay("2026-03-02"); + expect(dayValue("HEART_RATE_VARIABILITY", rows, "UTC")).toBe( + rows.reduce((s, r) => s + r.value, 0) / rows.length, + ); + }); + + it("is null without readings", () => { + expect(dayValue("PULSE", [], "UTC")).toBeNull(); + }); + + it("buckets hours on the zone's own half-hour boundaries", () => { + // Asia/Kolkata is UTC+05:30: 10:20Z and 10:40Z are 15:50 and 16:10 local, + // two different local hours, though one UTC hour. + const rows = [ + { value: 100, measuredAt: at("2026-03-02T10:20:00Z") }, + { value: 100, measuredAt: at("2026-03-02T10:25:00Z") }, + { value: 40, measuredAt: at("2026-03-02T10:40:00Z") }, + ]; + expect(dayValue("PULSE", rows, "Asia/Kolkata")).toBe(70); + expect(dayValue("PULSE", rows, "UTC")).toBe(80); + }); +}); + +describe("readingsMean", () => { + it("weighs each local day once for pulse", () => { + const rows = [ + ...workoutDay("2026-03-02"), + { value: 60, measuredAt: at("2026-03-03T08:00:00Z") }, + ]; + expect(readingsMean("PULSE", rows, "UTC")).toBeCloseTo((82.5 + 60) / 2, 10); + }); + + it("splits days at the zone's midnight", () => { + // 22:30Z is already the next day in Berlin (UTC+1 in March). + const rows = [ + { value: 100, measuredAt: at("2026-03-02T10:00:00Z") }, + { value: 100, measuredAt: at("2026-03-02T11:00:00Z") }, + { value: 40, measuredAt: at("2026-03-02T23:30:00Z") }, + ]; + expect(readingsMean("PULSE", rows, "UTC")).toBe(80); + expect(readingsMean("PULSE", rows, "Europe/Berlin")).toBe(70); + }); + + it("keeps the plain left-to-right mean for every other type", () => { + const rows = [0.1, 0.2, 0.3].map((value, i) => ({ + value, + measuredAt: at(`2026-03-0${i + 1}T08:00:00Z`), + })); + expect(readingsMean("WEIGHT", rows, "UTC")).toBe((0 + 0.1 + 0.2 + 0.3) / 3); + }); + + it("is null without readings", () => { + expect(readingsMean("PULSE", [], "UTC")).toBeNull(); + expect(readingsMean("WEIGHT", [], "UTC")).toBeNull(); + }); +}); + +describe("hourMeanRows / dayValueRows", () => { + it("collapse pulse to one row per hour / per day", () => { + const rows = workoutDay("2026-03-02"); + const hours = hourMeanRows("PULSE", rows, "UTC"); + expect(hours.map((h) => h.value)).toEqual([150, 60, 60, 60]); + expect(hours[0].measuredAt.toISOString()).toBe("2026-03-02T10:00:00.000Z"); + const days = dayValueRows("PULSE", rows, "UTC"); + expect(days).toHaveLength(1); + expect(days[0].value).toBeCloseTo(82.5, 10); + }); + + it("hand every other type back untouched", () => { + const rows = workoutDay("2026-03-02"); + expect(hourMeanRows("BLOOD_GLUCOSE", rows, "UTC")).toBe(rows); + expect(dayValueRows("BLOOD_GLUCOSE", rows, "UTC")).toBe(rows); + }); +}); + +describe("SQL builders", () => { + it("splice only the closed type list", () => { + expect(isHourlyMeanTypeSql('m."type"')).toBe( + `(m."type")::text IN ('PULSE')`, + ); + }); + + it("refuse a non-identifier source and an inline zone", () => { + expect(() => dayWeightedRows("cm; DROP", SESSION_DAY_FRAME)).toThrow(); + expect(() => zoneDayFrame("'Europe/Berlin'")).toThrow(); + expect(zoneDayFrame("$3")).toEqual({ kind: "zone", tzSql: "$3" }); + }); + + it("keep AVG for every other type and weigh pulse", () => { + const sql = windowMeanSql({ + typeColumn: 'm."type"', + value: 'm."value"', + weight: "m.day_weight", + filter: "m.x", + }); + expect(sql).toContain('ELSE AVG(m."value") FILTER (WHERE m.x) END'); + expect(sql).toContain('SUM(m."value" * m.day_weight) FILTER (WHERE m.x)'); + const fold = foldMeanSql({ + typeColumn: 'c."type"', + total: "c.total", + count: "c.cnt", + weightedSum: "c.wsum", + weightSum: "c.wdays", + }); + expect(fold).toContain("ELSE SUM(c.total) / SUM(c.cnt) END"); + }); +}); diff --git a/src/lib/measurements/day-mean.ts b/src/lib/measurements/day-mean.ts new file mode 100644 index 000000000..38b2e2970 --- /dev/null +++ b/src/lib/measurements/day-mean.ts @@ -0,0 +1,358 @@ +/** + * The one place that turns readings into a day value and a window mean for the + * types in `HOURLY_MEAN_DAY_TYPES` (see `day-statistic.ts`). + * + * For such a type a day is the mean of its hours' means, each hour weighing + * one, and a window of days is the mean of its day values, each day weighing + * one. Count, min and max stay over every reading. Every other type keeps the + * plain mean over its readings, computed exactly as before. + * + * "Day" and "hour" are the reader's own frame: a reader that buckets by the + * user's local day passes that zone, a reader that buckets by the session's + * (UTC) day passes `SESSION_DAY_FRAME`. The hours are always the hours of + * that same frame, so a day never splits an hour. + * + * Two shapes are offered, one per kind of reader: + * + * - TypeScript, for readers that hold the readings in memory: + * `readingsMean`, `dayValue` and `dayValueRows`. + * - SQL, for readers that aggregate in Postgres: `dayWeightedRows` attaches + * a `day_weight` to every reading (1 / (readings in its hour × hours in its + * day) for an hourly-mean type, 1 otherwise), so a day's weights add up to + * one and `SUM(value * day_weight) / SUM(day_weight)` is the mean of the + * day values over any window. `windowMeanSql` and `foldMeanSql` build the + * mean expressions, falling back to the unchanged plain mean for every + * other type. + * + * Type lists are closed code constants checked against an enum-shaped regex + * before they are spliced; every value (user id, zone, bounds) stays a bound + * parameter of the caller's query. + */ +import { Prisma } from "@/generated/prisma/client"; +import { tzOffsetMinutes } from "@/lib/tz/format"; + +import { HOURLY_MEAN_DAY_TYPES, usesHourlyMeanDay } from "./day-statistic"; + +const MS_PER_MINUTE = 60_000; +const MS_PER_HOUR = 3_600_000; +const MS_PER_DAY = 86_400_000; +/** Zone offsets change on quarter hours at the finest, so a quarter-hour slot shares one offset. */ +const OFFSET_SLOT_MS = 15 * MS_PER_MINUTE; + +export interface DayReading { + value: number; + measuredAt: Date; +} + +// ─── TypeScript ─────────────────────────────────────────── + +/** Local wall-clock milliseconds of an instant, with a per-call offset cache. */ +function makeLocalMs(tz: string): (at: Date) => number { + const offsets = new Map(); + return (at: Date) => { + const t = at.getTime(); + const slot = Math.floor(t / OFFSET_SLOT_MS); + let offset = offsets.get(slot); + if (offset === undefined) { + offset = tzOffsetMinutes(at, tz); + offsets.set(slot, offset); + } + return t + offset * MS_PER_MINUTE; + }; +} + +function plainMean(rows: readonly DayReading[]): number | null { + if (rows.length === 0) return null; + return rows.reduce((s, r) => s + r.value, 0) / rows.length; +} + +/** Mean of the per-hour means of readings that share one day. */ +function meanOfHourMeans( + rows: readonly DayReading[], + localMs: (at: Date) => number, +): number { + const hours = new Map(); + for (const r of rows) { + const hour = Math.floor(localMs(r.measuredAt) / MS_PER_HOUR); + const acc = hours.get(hour); + if (acc) { + acc.sum += r.value; + acc.n += 1; + } else { + hours.set(hour, { sum: r.value, n: 1 }); + } + } + let sum = 0; + for (const h of hours.values()) sum += h.sum / h.n; + return sum / hours.size; +} + +/** Group readings by local day (days since the epoch in `tz`). */ +function groupByLocalDay( + rows: readonly T[], + localMs: (at: Date) => number, +): Map { + const days = new Map(); + for (const r of rows) { + const day = Math.floor(localMs(r.measuredAt) / MS_PER_DAY); + const list = days.get(day); + if (list) list.push(r); + else days.set(day, [r]); + } + return days; +} + +/** + * The value of ONE day's readings: the mean of its local hours' means for an + * hourly-mean type, the plain mean otherwise. `null` for no readings. + */ +export function dayValue( + type: string, + rows: readonly DayReading[], + tz: string, +): number | null { + if (rows.length === 0) return null; + if (!usesHourlyMeanDay(type)) return plainMean(rows); + return meanOfHourMeans(rows, makeLocalMs(tz)); +} + +/** + * The mean of a window of readings: for an hourly-mean type the mean of its + * local days' values (each day the mean of its hours' means), otherwise the + * plain mean over every reading, summed left to right as the readers always + * did. `null` for no readings. + */ +export function readingsMean( + type: string, + rows: readonly DayReading[], + tz: string, +): number | null { + if (rows.length === 0) return null; + if (!usesHourlyMeanDay(type)) return plainMean(rows); + const localMs = makeLocalMs(tz); + let sum = 0; + let days = 0; + for (const dayRows of groupByLocalDay(rows, localMs).values()) { + sum += meanOfHourMeans(dayRows, localMs); + days += 1; + } + return sum / days; +} + +/** + * Readings of an hourly-mean type replaced by one row per local hour carrying + * the hour's mean, stamped at the hour's first reading. A reader that already + * folds its rows into day buckets by plain mean (and a window by mean of + * those) then yields the hourly-mean day without knowing the statistic. Every + * other type comes back untouched (the same array). + */ +export function hourMeanRows( + type: string, + rows: readonly T[], + tz: string, +): readonly DayReading[] { + if (!usesHourlyMeanDay(type) || rows.length === 0) return rows; + const localMs = makeLocalMs(tz); + const hours = new Map(); + for (const r of rows) { + const hour = Math.floor(localMs(r.measuredAt) / MS_PER_HOUR); + const acc = hours.get(hour); + if (acc) { + acc.sum += r.value; + acc.n += 1; + if (r.measuredAt < acc.at) acc.at = r.measuredAt; + } else { + hours.set(hour, { sum: r.value, n: 1, at: r.measuredAt }); + } + } + return [...hours.values()] + .map((h) => ({ value: h.sum / h.n, measuredAt: h.at })) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); +} + +/** + * Readings of an hourly-mean type replaced by one row per local day carrying + * the day value, stamped at the day's first reading; a reader that folds rows + * into a window by plain mean then weighs every day once. Every other type + * comes back untouched (the same array). + */ +export function dayValueRows( + type: string, + rows: readonly T[], + tz: string, +): readonly DayReading[] { + if (!usesHourlyMeanDay(type) || rows.length === 0) return rows; + const localMs = makeLocalMs(tz); + const out: DayReading[] = []; + for (const dayRows of groupByLocalDay(rows, localMs).values()) { + let first = dayRows[0].measuredAt; + for (const r of dayRows) if (r.measuredAt < first) first = r.measuredAt; + out.push({ value: meanOfHourMeans(dayRows, localMs), measuredAt: first }); + } + return out.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); +} + +// ─── SQL ────────────────────────────────────────────────── + +const ENUM_RE = /^[A-Z0-9_]+$/; + +/** `'PULSE'` — the hourly-mean types as a spliceable text-literal list. */ +export function hourlyMeanTypeLiterals(): string { + return [...HOURLY_MEAN_DAY_TYPES] + .map((t) => { + if (!ENUM_RE.test(t)) { + throw new Error(`unsafe enum literal for SQL splice: ${t}`); + } + return `'${t}'`; + }) + .join(","); +} + +/** SQL predicate: the type column names an hourly-mean type. */ +export function isHourlyMeanTypeSql(typeColumn: string): string { + return `(${typeColumn})::text IN (${hourlyMeanTypeLiterals()})`; +} + +/** + * The frame a reader buckets its days in. `SESSION_DAY_FRAME` is + * `date_trunc('day', measured_at)` on the timestamptz itself, the frame of the + * rollup tier and of every reader that groups by that expression. A zone frame + * names a bound parameter (`$3`) or a Prisma placeholder carrying an IANA zone. + */ +export type DayFrame = { kind: "session" } | { kind: "zone"; tzSql: string }; + +export const SESSION_DAY_FRAME: DayFrame = { kind: "session" }; + +/** A zone frame whose zone is the caller's bound parameter `tzSql` (e.g. `$3`). */ +export function zoneDayFrame(tzSql: string): DayFrame { + if (!/^\$\d+$/.test(tzSql) && tzSql !== "'UTC'") { + throw new Error(`zone frame must be a bound parameter: ${tzSql}`); + } + return { kind: "zone", tzSql }; +} + +function frameTs(frame: DayFrame, column: string): string { + return frame.kind === "session" + ? column + : `((${column} AT TIME ZONE 'UTC') AT TIME ZONE ${frame.tzSql})`; +} + +function weightedRowsSql( + source: string, + frame: DayFrame, + bySource: boolean, +): string { + if (!/^[a-z_][a-z0-9_]*$/.test(source)) { + throw new Error(`source must be a CTE name: ${source}`); + } + const hourOf = (alias: string) => + `date_trunc('hour', ${frameTs(frame, `${alias}."measured_at"`)})`; + const dayOf = (alias: string) => + `date_trunc('day', ${frameTs(frame, `${alias}."measured_at"`)})`; + const src = bySource ? `, t."source"` : ""; + const part = bySource ? `, x."source"` : ""; + const join = bySource ? ` AND hw."source" = s."source"` : ""; + return `( + SELECT s.*, + (CASE WHEN ${isHourlyMeanTypeSql('s."type"')} + THEN 1.0 / (hw.n * hw.hours) + ELSE 1.0 END)::double precision AS day_weight + FROM ${source} s + LEFT JOIN ( + SELECT x."type"${part}, x.local_hour, x.n, + COUNT(*) OVER (PARTITION BY x."type"${part}, x.local_day) AS hours + FROM ( + SELECT t."type"${src}, + ${hourOf("t")} AS local_hour, + ${dayOf("t")} AS local_day, + COUNT(*) AS n + FROM ${source} t + WHERE ${isHourlyMeanTypeSql('t."type"')} + GROUP BY ${bySource ? "1, 2, 3, 4" : "1, 2, 3"} + ) x + ) hw + ON hw."type" = s."type"${join} + AND hw.local_hour = ${hourOf("s")} + )`; +} + +/** + * A subquery over the CTE `source` (which must expose `type`, `measured_at` + * and, with `bySource`, `source`) that adds `day_weight` to every row. Alias + * it at the call site: `FROM ${dayWeightedRows("cm", frame)} m`. + * + * `bySource` keys the hours per source, for a reader that folds each source's + * days before it picks one; a reader over one canonical source per day leaves + * it off. + */ +export function dayWeightedRows( + source: string, + frame: DayFrame, + opts: { bySource?: boolean } = {}, +): string { + return weightedRowsSql(source, frame, opts.bySource === true); +} + +/** + * The Prisma tagged-template twin of `dayWeightedRows`, for readers written + * with `prisma.$queryRaw`: the zone, when given, is a bound parameter. + */ +export function dayWeightedRowsSql( + source: string, + tz: string | null, + opts: { bySource?: boolean } = {}, +): Prisma.Sql { + if (tz === null) { + return Prisma.raw( + weightedRowsSql(source, SESSION_DAY_FRAME, opts.bySource === true), + ); + } + // Split around a placeholder so the zone binds as a parameter. + const marker = "__DAY_MEAN_TZ__"; + const pieces = weightedRowsSql( + source, + { kind: "zone", tzSql: marker }, + opts.bySource === true, + ).split(marker); + let sql = Prisma.raw(pieces[0]); + for (const piece of pieces.slice(1)) { + sql = Prisma.sql`${sql}${tz}${Prisma.raw(piece)}`; + } + return sql; +} + +/** + * Window mean over weighted rows (`dayWeightedRows`): the mean of the day + * values for an hourly-mean type, the unchanged `AVG(value)` otherwise. The + * query groups by the type column (or filters to one type). `filter` is an + * optional SQL predicate applied as an aggregate FILTER. + */ +export function windowMeanSql(opts: { + typeColumn: string; + value: string; + weight: string; + filter?: string; +}): string { + const f = opts.filter ? ` FILTER (WHERE ${opts.filter})` : ""; + return `(CASE WHEN ${isHourlyMeanTypeSql(opts.typeColumn)} + THEN SUM(${opts.value} * ${opts.weight})${f} / NULLIF(SUM(${opts.weight})${f}, 0) + ELSE AVG(${opts.value})${f} END)`; +} + +/** + * Fold of per-day rows into a window mean: `SUM(wsum) / SUM(wdays)` (each day + * once) for an hourly-mean type, the unchanged `SUM(total) / SUM(cnt)` + * otherwise. `wsum` / `wdays` are `SUM(value * day_weight)` / + * `SUM(day_weight)` per day. + */ +export function foldMeanSql(opts: { + typeColumn: string; + total: string; + count: string; + weightedSum: string; + weightSum: string; +}): string { + return `(CASE WHEN ${isHourlyMeanTypeSql(opts.typeColumn)} + THEN SUM(${opts.weightedSum}) / NULLIF(SUM(${opts.weightSum}), 0) + ELSE SUM(${opts.total}) / SUM(${opts.count}) END)`; +}