From 24568c805b569cd1b24d9a5948b597240330d566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 12:00:56 +0200 Subject: [PATCH] perf(summaries-slice): consume monthly rollup buckets for long-window slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires readBestGranularityRollups into computeSummariesSlice to populate the year-ago 30-day baseline (avg30LastYear) per type. The field was hardcoded null up to v1.4.39 because the 90-day windowed aggregate cannot reach back a year and the live measurements walk over a 395-day per-type window is exactly the cold-path cost the rollup tier was meant to absorb. Coverage-fallback mirrors the W-MOOD posture: per-type calls route through YEAR (skipped for the 395-day window — floor 731 d) -> MONTH -> WEEK -> DAY and return null when no granularity carries any bucket overlapping the [now-395d, now-365d) slice. count / mean compose linearly across any granularity so the derived baseline is mathematically equivalent to the per-row average over the same window. Adds year_over_year_types annotate count alongside the existing slim_summaries path tag so operators can verify the wire-up in production wide-events without redeploying instrumentation. --- .../__tests__/summaries-slice.test.ts | 159 +++++++++++++++++- src/lib/analytics/summaries-slice.ts | 136 +++++++++++++++ 2 files changed, 294 insertions(+), 1 deletion(-) diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 4b379d71d..2f7c79039 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -291,7 +291,164 @@ describe("computeSummariesSlice", () => { // probe + narrow aggregate + latests + rollup GROUP BY // (v1.4.37.2 — the prior `findMany` is gone). No heavy aggregate. expect(RAW).toHaveBeenCalledTimes(4); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(0); + // v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs + // `readBestGranularityRollups(userId, type, 395)` per + // type-with-data via `prisma.measurementRollup.findMany`. The + // 395-day window skips the YEAR floor (731 d) and walks MONTH + // → WEEK → DAY on full coverage miss; the default mock returns + // `[]` so all three reachable tiers probe. + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); + }); + }); + + /** + * v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the + * slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to + * `null`; we now populate it from `readBestGranularityRollups` when + * the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap + * the `[now-395d, now-365d)` slice. + */ + describe("year-over-year wiring (avg30LastYear)", () => { + it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => { + // Coverage probe shows WEIGHT covered → rollup happy path. + RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]) + .mockResolvedValueOnce([ + { + type: "WEIGHT", + avg7: 82, + avg30: 82.5, + slope7: 0, + r2_7: 0, + slope30: 0, + r2_30: 0, + slope90: 0, + r2_90: 0, + }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", value: 82.7, measured_at: new Date() }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 }, + ]); + + // Year-ago slice MONTH bucket — `bucketStart` placed 380 days + // ago so it falls inside `[now-395d, now-365d)`. Single bucket + // (count=10, mean=85) → weighted mean = 85. + const yearAgoBucketStart = new Date( + Date.now() - 380 * 24 * 60 * 60 * 1000, + ); + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: yearAgoBucketStart, + count: 10, + mean: 85, + sd: 1, + slope: 0, + r2: 0, + sumValue: null, + minValue: 83, + maxValue: 87, + }, + ]); + + const result = await computeSummariesSlice("user-yoy"); + + expect(result.summaries.WEIGHT.avg30LastYear).toBe(85); + // Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR). + expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH"); + }); + + it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => { + RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]) + .mockResolvedValueOnce([ + { + type: "WEIGHT", + avg7: 82, + avg30: 82.5, + slope7: 0, + r2_7: 0, + slope30: 0, + r2_30: 0, + slope90: 0, + r2_90: 0, + }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", value: 82.7, measured_at: new Date() }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 }, + ]); + + // MONTH bucket placed 30 days ago — inside the YEAR / MONTH / + // WEEK 395-day window the router asks for, but outside the + // `[now-395d, now-365d)` slice. The helper returns null. + const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: recentBucket, + count: 10, + mean: 82, + sd: 1, + slope: 0, + r2: 0, + sumValue: null, + minValue: 80, + maxValue: 84, + }, + ]); + + const result = await computeSummariesSlice("user-recent"); + + expect(result.summaries.WEIGHT.avg30LastYear).toBeNull(); + }); + + it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => { + RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]) + .mockResolvedValueOnce([ + { + type: "WEIGHT", + avg7: 82, + avg30: 82.5, + slope7: 0, + r2_7: 0, + slope30: 0, + r2_30: 0, + slope90: 0, + r2_90: 0, + }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", value: 82.7, measured_at: new Date() }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 }, + ]); + + // Default mock returns `[]` for every findMany call → router + // walks YEAR → MONTH → WEEK → DAY and gives up. + ROLLUP_FIND_MANY.mockResolvedValue([]); + + const result = await computeSummariesSlice("user-empty-yoy"); + + expect(result.summaries.WEIGHT.avg30LastYear).toBeNull(); + // 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY, + // three reachable tiers all probed on full coverage miss. + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); + }); + + it("only probes types that actually have data in the current window", async () => { + // Coverage probe + rollup GROUP BY both report zero rows → no + // types-with-data, so the year-ago probe must NOT fan out. + RAW.mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await computeSummariesSlice("user-no-data"); + + expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); }); }); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 33dd0dd4a..6ebf1b75d 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -70,6 +70,8 @@ import { type RollupBucketRow, } from "@/lib/measurements/rollup-read-wmy"; +const DAY_MS = 24 * 60 * 60 * 1000; + /** * Heavy aggregate row — used on the cold-mount fallback path where the * rollup table is empty and we need every per-type column out of one @@ -390,10 +392,12 @@ async function computeFromRollups(userId: string): Promise { let totalRows = 0; let typeCount = 0; + const typesWithData: string[] = []; for (const row of dayBuckets) { if (!row.count || row.count <= 0) continue; typeCount += 1; totalRows += row.count; + typesWithData.push(row.type); const narrow = narrowByType.get(row.type); const latest = latestByType.get(row.type) ?? null; summaries[row.type] = { @@ -413,6 +417,21 @@ async function computeFromRollups(userId: string): Promise { }; } + // v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the + // WMY rollup tier. Only types with data in the current window are + // probed so we don't pay the per-type round-trip on the long tail + // of unlogged measurement enums. Types without YEAR/MONTH coverage + // surface as `null` (existing behaviour) — additive only. + const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData); + let yearOverYearTypeCount = 0; + for (const [type, value] of avg30LastYearMap.entries()) { + if (value === null) continue; + const summary = summaries[type]; + if (!summary) continue; + summary.avg30LastYear = round2(value); + yearOverYearTypeCount += 1; + } + annotate({ action: { name: "analytics.get.slim" }, meta: { @@ -421,6 +440,7 @@ async function computeFromRollups(userId: string): Promise { row_count: totalRows, type_count: typeCount, path: "rollup", + year_over_year_types: yearOverYearTypeCount, }, }, }, @@ -535,9 +555,11 @@ async function computeFromLiveAggregate( } let totalRows = 0; + const typesWithData: string[] = []; for (const row of aggregates) { const count = Number(row.count); totalRows += count; + typesWithData.push(row.type); const latest = latestByType.get(row.type) ?? null; summaries[row.type] = { count, @@ -556,6 +578,23 @@ async function computeFromLiveAggregate( }; } + // v1.4.40 W-WMY-WIRE — even on the live-aggregate fallback (DAY + // coverage incomplete), the YEAR / MONTH rollup tier may still + // carry the year-ago baseline because the boot-time backfill mints + // all granularities once per user. Probing here means a partial- + // coverage cold mount still surfaces `avg30LastYear` whenever the + // long-tail tier is populated, instead of waiting for the next + // refresh cycle. + const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData); + let yearOverYearTypeCount = 0; + for (const [type, value] of avg30LastYearMap.entries()) { + if (value === null) continue; + const summary = summaries[type]; + if (!summary) continue; + summary.avg30LastYear = round2(value); + yearOverYearTypeCount += 1; + } + annotate({ action: { name: "analytics.get.slim" }, meta: { @@ -564,6 +603,7 @@ async function computeFromLiveAggregate( row_count: totalRows, type_count: aggregates.length, path: "live", + year_over_year_types: yearOverYearTypeCount, }, }, }, @@ -664,3 +704,99 @@ export async function computeLongWindowSummary( sum: aggregate.sum, }; } + +/** + * v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from + * the WEEK / MONTH / YEAR rollup tier. + * + * Why this helper exists + * ---------------------- + * `DataSummary.avg30LastYear` is the mean over `[now-395d, now-365d)` — + * the 30-day window starting 365 days ago. The dashboard tile-strip's + * delta callout uses it to narrate "current avg vs same time last + * year". Up to v1.4.39 the slim slice (and the comprehensive + * aggregator's rollup branch) hardcoded the field to `null` because + * the 90-day windowed `$queryRaw` cannot reach back a year, and the + * live `measurements` table walk for a 395-day per-type window is + * exactly the cold-path cost we're trying to avoid. + * + * The WMY rollup tier already carries the per-bucket stats this + * window needs. `readBestGranularityRollups(userId, type, 395)` + * routes the 395-day window through YEAR (if covered) → MONTH → WEEK + * → DAY and returns the buckets whose `bucketStart` falls inside the + * window. We then keep only the buckets that overlap the + * `[now-395d, now-365d)` slice and compose `count / mean` linearly to + * produce the year-ago baseline. + * + * Compositional contract + * ---------------------- + * `count / mean` are linearly composable across any granularity, so + * the year-ago mean derived from MONTH or YEAR buckets is + * mathematically equivalent to the per-row average over the same + * window. The bucket-overlap filter is conservative: we include only + * buckets whose `bucketStart` is strictly inside the slice. On a + * YEAR-granularity routing this collapses to at most one bucket and + * approximates the 30-day mean by the surrounding yearly average — + * acceptable for a UI hint that explicitly narrates "last year". + * + * Coverage-miss policy + * -------------------- + * Returns `null` when `readBestGranularityRollups` returns null (no + * coverage at any granularity) OR when no buckets overlap the + * year-ago slice. The caller leaves the dashboard field as `null`, + * which the UI already handles as "no comparison available". + */ +async function computeAvg30LastYearForType( + userId: string, + type: MeasurementType, +): Promise { + const resolved = await readBestGranularityRollups(userId, type, 395); + if (!resolved) return null; + const now = Date.now(); + const sliceStart = now - 395 * DAY_MS; + const sliceEnd = now - 365 * DAY_MS; + const overlapping = resolved.rows.filter((row) => { + const t = row.bucketStart.getTime(); + return t >= sliceStart && t < sliceEnd; + }); + if (overlapping.length === 0) return null; + let totalCount = 0; + let weighted = 0; + for (const row of overlapping) { + totalCount += row.count; + weighted += row.count * row.mean; + } + if (totalCount === 0) return null; + return weighted / totalCount; +} + +/** + * v1.4.40 W-WMY-WIRE — fan out `computeAvg30LastYearForType` across + * the types the caller actually surfaces. Runs the per-type WMY reads + * in parallel so the long-tail of types doesn't serialise into a + * per-type round-trip stack. + * + * Returns a `type → mean | null` map; types with no coverage at any + * granularity (or no buckets in the year-ago slice) map to `null` so + * the caller can blanket-assign without per-type null-checking. + */ +async function computeAvg30LastYearMap( + userId: string, + types: ReadonlyArray, +): Promise> { + const out = new Map(); + if (types.length === 0) return out; + const results = await Promise.all( + types.map(async (type) => { + const value = await computeAvg30LastYearForType( + userId, + type as MeasurementType, + ); + return [type, value] as const; + }), + ); + for (const [type, value] of results) { + out.set(type, value); + } + return out; +}