mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50: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.
75 lines
3.2 KiB
TypeScript
75 lines
3.2 KiB
TypeScript
/**
|
|
* The live-SQL slope windows and their trend evidence, as SELECT columns.
|
|
*
|
|
* The cold-fallback summaries (`summaries-slice.ts`, the comprehensive
|
|
* aggregator) fit `slope7` / `slope30` with `REGR_SLOPE` over the canonical
|
|
* readings. Whether a window may publish that slope is decided in JS by
|
|
* `toTrendSlope` from the window's distinct reading days and their span (see
|
|
* `MIN_TREND_DAYS` in `window-confidence.ts`); the evidence columns hand it
|
|
* exactly that, over the same rows and the same window bounds, so the live
|
|
* path withholds a slope on the same evidence the warm rollup path does
|
|
* (`composeWindowedTrend`).
|
|
*
|
|
* The windows anchor on the UTC-midnight day boundary of the request's `now`
|
|
* (`date_trunc('day', now) - N days`, the SQL twin of `startOfUtcDay(now - N
|
|
* days)`), not on the instant, so the cold path counts the same boundary day
|
|
* the warm path composes from: with an instant bound the boundary day flipped
|
|
* in and out of the slope window across a warm-to-cold coverage transition.
|
|
* `now` is a `boundInstantSql` expression (see `request-clock-sql.ts`).
|
|
*
|
|
* `measured_at` is a naive UTC timestamp, so `::date` is its UTC calendar
|
|
* day, the day the rollup tier buckets by; `date - date` is whole days. It
|
|
* expects the row alias `m` and a `GROUP BY` per type.
|
|
*/
|
|
import { assertInstantSql } from "@/lib/analytics/request-clock-sql";
|
|
import { toTrendSlope, type TrendSlope } from "@/lib/analytics/trends";
|
|
|
|
function windowColumns(now: string, days: 7 | 30): string {
|
|
const filter = `m."measured_at" >= (date_trunc('day', ${now}) - INTERVAL '${days} days')`;
|
|
const x = `EXTRACT(EPOCH FROM m."measured_at") / 86400.0`;
|
|
const r2 = days === 7 ? "r2_7" : "r2_30";
|
|
return `REGR_SLOPE(m."value", ${x}) FILTER (WHERE ${filter})::double precision AS slope${days},
|
|
REGR_R2(m."value", ${x}) FILTER (WHERE ${filter})::double precision AS ${r2},
|
|
COUNT(DISTINCT m."measured_at"::date) FILTER (WHERE ${filter})::int AS days${days},
|
|
(MAX(m."measured_at"::date) FILTER (WHERE ${filter})
|
|
- MIN(m."measured_at"::date) FILTER (WHERE ${filter}))::int AS span${days}`;
|
|
}
|
|
|
|
/** `slope7, r2_7, days7, span7` and the 30-day four for a per-type live aggregate. */
|
|
export function liveTrendSql(now: string): string {
|
|
const at = assertInstantSql(now);
|
|
return `${windowColumns(at, 7)},
|
|
${windowColumns(at, 30)}`;
|
|
}
|
|
|
|
/** The slope, r² and evidence columns a live per-type aggregate returns. */
|
|
export interface LiveTrendColumns {
|
|
slope7: number | null;
|
|
r2_7: number | null;
|
|
slope30: number | null;
|
|
r2_30: number | null;
|
|
/** UTC days holding a reading in each slope window, and their span. */
|
|
days7: number | null;
|
|
span7: number | null;
|
|
days30: number | null;
|
|
span30: number | null;
|
|
}
|
|
|
|
/** The published trend of one live window, gated on its evidence columns. */
|
|
export function liveTrend(
|
|
row: LiveTrendColumns | undefined,
|
|
windowDays: 7 | 30,
|
|
): TrendSlope | null {
|
|
if (!row) return null;
|
|
const [slope, r2, days, span] =
|
|
windowDays === 7
|
|
? [row.slope7, row.r2_7, row.days7, row.span7]
|
|
: [row.slope30, row.r2_30, row.days30, row.span30];
|
|
return toTrendSlope(
|
|
slope,
|
|
r2,
|
|
{ days: days ?? 0, spanDays: span ?? 0 },
|
|
windowDays,
|
|
);
|
|
}
|