docs(insights): pin the median window contract across the summary paths

DataSummary.median carries two windows under one field name: the SQL
summaries-slice path windows PERCENTILE_CONT to the trailing 90 days,
while the JS summarize() takes the median over the full series it is
handed (a caller-defined window). No user-visible bug today — the stat
strip only reads the 90-day SQL path — but a future consumer reading
summary.median off a summarize()-derived payload would silently get a
different number. Document the window on the type and at both call sites
so the divergence is explicit; the percentile algorithm matches, only
the window differs.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 21:17:09 +02:00
1 parent 146867b09f
commit 0b10932c53
2 files changed
+27 -6

No files matched your search

+6
View File
@@ -117,6 +117,12 @@ interface NarrowAggregateRow {
* sorting every raw row per type, which the rollup tier cannot
* compose and which the perf posture (v1.8.3 anti-freeze) forbids on
* the read path. Null when the window holds no rows.
*
* NOTE: this window (trailing 90 days) is the contract for the stat
* strip's `DataSummary.median`. The JS `summarize()` in `trends.ts`
* computes its median over the *full* series it is handed (a
* caller-defined window), so the two share a field name but not a
* window — see the `median` doc on `DataSummary`.
*/
median: number | null;
avg30_last_month: number | null;
+21 -6
View File
@@ -125,6 +125,17 @@ export interface DataSummary {
* the insights category-page stat strip alongside min / max / mean so
* a skewed series (a few very high or very low readings dragging the
* mean) reads honestly. Null when the series is empty.
*
* Window: caller-defined. `summarize()` computes the median over the
* full `DataPoint[]` it is handed, so the window is whatever the caller
* passed (full history for `/api/measurements/series`, per-day means
* for the snapshot builder, …). It is NOT guaranteed to be the trailing
* 90-day window. The insights stat strip does not read this value — its
* median comes from the SQL `summaries-slice` path, which fixes the
* window to the trailing 90 days (`PERCENTILE_CONT(0.5) … FILTER (WHERE
* measured_at >= NOW() - INTERVAL '90 days')`). A consumer comparing the
* two must account for the differing windows. The percentile *algorithm*
* matches (linear-interpolated midpoint), only the window differs.
*/
median: number | null;
avg7: number | null;
@@ -192,12 +203,16 @@ export function summarize(data: DataPoint[]): DataSummary {
}
const mean = sum / values.length;
// v1.8.5 — median (50th percentile) over the full series. A separate
// numeric sort from the date-ordered `sorted` array above so the
// percentile is taken over values, not timestamps. Linear-interpolated
// midpoint on an even-length series (the standard PERCENTILE_CONT
// definition) so this matches the SQL `PERCENTILE_CONT(0.5)` the
// rollup-tier slim slice emits.
// v1.8.5 — median (50th percentile) over the FULL series handed to
// this function (the window is caller-defined; see the `median` doc on
// DataSummary). A separate numeric sort from the date-ordered `sorted`
// array above so the percentile is taken over values, not timestamps.
// Linear-interpolated midpoint on an even-length series (the standard
// PERCENTILE_CONT definition) so the *algorithm* matches the SQL
// `PERCENTILE_CONT(0.5)` the rollup-tier slim slice emits — but note
// the SQL path windows to the trailing 90 days while this path does
// not, so the two are byte-comparable only when the caller already
// passed a 90-day series.
const valueSorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(valueSorted.length / 2);
const median =