Add one day-mean helper for the hourly-mean types

A day of pulse is the mean of its hours' means and a window the mean of its
days, but only the rollup tier and the chart knew it. The helper gives every
other reader the same statistic in both shapes it needs: in TypeScript over
readings it holds, and in SQL as a per-reading day weight whose sums give the
mean of the day values over any window. Every other type keeps its plain mean.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 11:05:35 +02:00
1 parent 497da7ca8b
commit c755a42d93
2 files changed
+503

No files matched your search

@@ -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");
});
});
+358
View File
@@ -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<number, number>();
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<number, { sum: number; n: number }>();
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<T extends DayReading>(
rows: readonly T[],
localMs: (at: Date) => number,
): Map<number, T[]> {
const days = new Map<number, T[]>();
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<T extends DayReading>(
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<number, { sum: number; n: number; at: Date }>();
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<T extends DayReading>(
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)`;
}