From 4eeaec74aaa36548461f7dda0278a93f1fe3c940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 06:24:37 +0200 Subject: [PATCH] Give the tiered context its promised live fallback and un-truncate all-time stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The tiered AI context's band reader now falls back to a live grouped aggregate when a rollup band is empty (the module doc promised replace-with-fallback from day one; only the replace half existed), so a coverage-miss or never-folded YEAR partition no longer hands the model empty history. Far-from-UTC users get the day-grain band keyed on their own calendar (the same ±3h guard the correlation readers carry). - The slim analytics slice splices measurements older than the 5-year fold window into its all-time count/min/max/mean: the rollup tier only covers the window, so "all time" silently truncated for long histories while the live fallback told the truth. A type whose only rows are pre-fold no longer reads as never logged. --- .../__tests__/summaries-slice.test.ts | 66 ++++- src/lib/analytics/summaries-slice.ts | 225 +++++++++++++----- .../rollups/__tests__/tiered-context.test.ts | 98 ++++++++ src/lib/rollups/tiered-context.ts | 134 ++++++++++- 4 files changed, 451 insertions(+), 72 deletions(-) diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index c1848be24..ce8c10f70 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -99,6 +99,10 @@ beforeEach(() => { ROLLUP_FIND_MANY.mockResolvedValue([]); ROLLUP_FIND_FIRST.mockResolvedValue(null); MEASUREMENT_FIND_FIRST.mockResolvedValue(null); + // v1.37.19 (A6-10) — default the tagged-template RAW reads (the coverage + // probe + the pre-fold all-time remainder) to empty; individual tests + // queue their own `mockResolvedValueOnce` sequences on top. + RAW.mockResolvedValue([]); }); afterEach(() => { @@ -404,8 +408,9 @@ describe("computeSummariesSlice", () => { // 1 RAW coverage probe + 3 UNSAFE data queries (narrow aggregate // + latests + rollup GROUP BY; v1.4.37.2 — the prior `findMany` - // is gone). No heavy aggregate. - expect(RAW).toHaveBeenCalledTimes(1); + // is gone). No heavy aggregate. v1.37.19 (A6-10) — the second RAW + // call is the pre-fold all-time remainder splice. + expect(RAW).toHaveBeenCalledTimes(2); expect(UNSAFE).toHaveBeenCalledTimes(3); // v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs // `readBestGranularityRollups(userId, type, 395)` per @@ -418,6 +423,63 @@ describe("computeSummariesSlice", () => { // now runs to compose slope / r² / sd: 1 accumulator + 3 WMY = 4. expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4); }); + + // Watched red: with the pre-fold splice removed from the rollup path + // (the pre-v1.37.19 assembly used the GROUP BY figures verbatim), the + // count / min / max / mean assertions fail — an account with rows + // older than the 5-year fold window had its "all-time" figures + // silently truncated to the window while the live fallback reported + // the true numbers. + it("splices rows older than the fold window into the all-time figures (A6-10)", async () => { + RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]) // probe + .mockResolvedValueOnce([ + // pre-fold remainder: 5 ancient readings, heavier and wider. + { type: "WEIGHT", count: 5, min: 70, max: 95, mean: 90 }, + ]); + UNSAFE.mockResolvedValueOnce([ + { type: "WEIGHT", avg7: 82, avg30: 82.5, median: 82.1 }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", value: 82.7, measured_at: new Date() }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", count: 20, min: 79.5, max: 84.0, mean: 82.0 }, + ]); + + const result = await computeSummariesSlice("user-prefold"); + const weight = result.summaries.WEIGHT; + + // 20 in-window + 5 pre-fold. + expect(weight.count).toBe(25); + // Envelope widens to the ancient extremes. + expect(weight.min).toBe(70); + expect(weight.max).toBe(95); + // Weighted mean: (82*20 + 90*5) / 25 = 83.6. + expect(weight.mean).toBe(83.6); + // Windowed figures stay window-scoped. + expect(weight.avg7).toBe(82); + }); + + it("surfaces a type whose only rows are pre-fold (no DAY bucket)", async () => { + RAW.mockResolvedValueOnce([ + { type: "WEIGHT", has_buckets: true }, + ]).mockResolvedValueOnce([ + { type: "HEIGHT", count: 2, min: 180, max: 181, mean: 180.5 }, + ]); + UNSAFE.mockResolvedValueOnce([]) // narrows + .mockResolvedValueOnce([]) // latests + .mockResolvedValueOnce([]); // rollup GROUP BY + + const result = await computeSummariesSlice("user-prefold-only"); + const height = result.summaries.HEIGHT; + expect(height.count).toBe(2); + expect(height.min).toBe(180); + expect(height.max).toBe(181); + expect(height.mean).toBe(180.5); + // No in-window rows: every windowed field stays null. + expect(height.avg7).toBeNull(); + expect(height.slope30).toBeNull(); + }); }); /** diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 5bedf39a4..7043ca2fc 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -62,7 +62,10 @@ import { prisma } from "@/lib/db"; import type { DataSummary } from "@/lib/analytics/trends"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { annotate } from "@/lib/logging/context"; -import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups"; +import { + ensureUserRollupsFresh, + ROLLUP_FOLD_WINDOW_MS, +} from "@/lib/rollups/measurement-rollups"; import { collapseRollupRowsBySource, composeWindowedRegression, @@ -421,13 +424,22 @@ async function computeFromRollups(userId: string): Promise { ); const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); - const [narrows, latests, dayBuckets, accumulatorRows] = await Promise.all([ - // The FROM is restricted to the canonical-source rows per (type, day): the - // inner DISTINCT ON picks the ladder-winning source for each day, and the - // join keeps only that source's readings so the 90-day AVG / median / slope - // never blend two devices that both reported the same vital. - prisma.$queryRawUnsafe( - ` + // v1.37.19 (A6-10) — where the fold window starts. DAY rollup buckets + // only exist inside the trailing `ROLLUP_FOLD_WINDOW_MS` (5 y), so the + // rollup-composed "all-time" figures silently truncated to that window + // for accounts with older history, while the cold live path reported + // the true unbounded numbers. The pre-fold remainder query below + // splices the older rows back in. + const foldWindowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); + + const [narrows, latests, dayBuckets, accumulatorRows, preFoldRemainder] = + await Promise.all([ + // The FROM is restricted to the canonical-source rows per (type, day): the + // inner DISTINCT ON picks the ladder-winning source for each day, and the + // join keeps only that source's readings so the 90-day AVG / median / slope + // never blend two devices that both reported the same vital. + prisma.$queryRawUnsafe( + ` SELECT m."type"::text AS type, AVG(m."value") FILTER ( @@ -448,13 +460,13 @@ async function computeFromRollups(userId: string): Promise { FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")} GROUP BY m."type" `, - userId, - ), - // v1.11.1 — the latest tile reflects the canonical source for the latest - // day, matching the chart: order by latest day first, then the ladder rank - // (canonical source wins), then the latest reading of that source. - prisma.$queryRawUnsafe( - ` + userId, + ), + // v1.11.1 — the latest tile reflects the canonical source for the latest + // day, matching the chart: order by latest day first, then the ladder rank + // (canonical source wins), then the latest reading of that source. + prisma.$queryRawUnsafe( + ` SELECT DISTINCT ON (m."type") m."type"::text AS type, m."value"::double precision AS value, @@ -464,24 +476,24 @@ async function computeFromRollups(userId: string): Promise { AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, - userId, - ), - // v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY - // rollup bucket for the user (`findMany` without a `bucketStart` - // window) and then composed `count / min / max / mean` in JS. - // On tenants with large measurement partitions that materialised - // as a six-figure row transfer + a JS loop = multi-second per - // cache miss, even with the rollup table hot. The slim slice's - // contract is the all-time count / min / max / mean per type — - // exactly what a SQL `GROUP BY type` returns in a single - // round-trip. - // - // v1.11.1 — rows are now per source. Collapse each (type, day) to the - // ladder-canonical source via DISTINCT ON before the all-time aggregate, - // so a dual-source vital is counted once. Still one server-side pass — - // the DISTINCT ON + GROUP BY runs in Postgres and returns one row/type. - prisma.$queryRawUnsafe( - ` + userId, + ), + // v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY + // rollup bucket for the user (`findMany` without a `bucketStart` + // window) and then composed `count / min / max / mean` in JS. + // On tenants with large measurement partitions that materialised + // as a six-figure row transfer + a JS loop = multi-second per + // cache miss, even with the rollup table hot. The slim slice's + // contract is the all-time count / min / max / mean per type — + // exactly what a SQL `GROUP BY type` returns in a single + // round-trip. + // + // v1.11.1 — rows are now per source. Collapse each (type, day) to the + // ladder-canonical source via DISTINCT ON before the all-time aggregate, + // so a dual-source vital is counted once. Still one server-side pass — + // the DISTINCT ON + GROUP BY runs in Postgres and returns one row/type. + prisma.$queryRawUnsafe( + ` WITH collapsed AS ( SELECT DISTINCT ON ("type", "bucket_start") "type" AS type, @@ -506,37 +518,81 @@ async function computeFromRollups(userId: string): Promise { FROM collapsed GROUP BY "type" `, - userId, - ), - // v1.20.0 F6 — trailing 90-day DAY rollup rows carrying the per-bucket - // regression accumulators. The windowed slope / r² / sd compose from - // these (composeWindowedRegression) instead of the live REGR_* scan the - // narrow query used to run. Rows are per source; collapsed per type - // below so a dual-source vital contributes only its canonical source's - // accumulators. Bounded to 90 days (≤ ~90 rows/type), the same window - // the dropped slope columns covered. - prisma.measurementRollup.findMany({ - where: { userId, - granularity: "DAY", - bucketStart: { - gte: startOfUtcDay(new Date(Date.now() - 90 * DAY_MS)), + ), + // v1.20.0 F6 — trailing 90-day DAY rollup rows carrying the per-bucket + // regression accumulators. The windowed slope / r² / sd compose from + // these (composeWindowedRegression) instead of the live REGR_* scan the + // narrow query used to run. Rows are per source; collapsed per type + // below so a dual-source vital contributes only its canonical source's + // accumulators. Bounded to 90 days (≤ ~90 rows/type), the same window + // the dropped slope columns covered. + prisma.measurementRollup.findMany({ + where: { + userId, + granularity: "DAY", + bucketStart: { + gte: startOfUtcDay(new Date(Date.now() - 90 * DAY_MS)), + }, }, - }, - orderBy: [{ type: "asc" }, { bucketStart: "asc" }], - select: { - type: true, - source: true, - bucketStart: true, - count: true, - mean: true, - sumX: true, - sumXy: true, - sumXx: true, - sumYy: true, - }, - }), - ]); + orderBy: [{ type: "asc" }, { bucketStart: "asc" }], + select: { + type: true, + source: true, + bucketStart: true, + count: true, + mean: true, + sumX: true, + sumXy: true, + sumXx: true, + sumYy: true, + }, + }), + // v1.37.19 (A6-10) — all-time remainder OLDER than the fold window. + // One grouped aggregate over the pre-fold rows (index range scan on + // `(user_id, measured_at)`; empty for any account younger than the + // window). Spliced into the per-type figures below so "all-time" + // means all time on the rollup path too. Deliberately without the + // source-priority collapse: pre-fold history predates the rollup + // tier's per-day canonicalisation, and a per-day DISTINCT ON over a + // multi-year span is the cost this splice exists to avoid — for + // count/min/max/mean over years-old data the raw figures are the + // honest ones. + prisma.$queryRaw< + Array<{ + type: string; + count: number; + min: number; + max: number; + mean: number; + }> + >` + SELECT + m."type"::text AS type, + COUNT(*)::int AS count, + MIN(m."value")::double precision AS min, + MAX(m."value")::double precision AS max, + AVG(m."value")::double precision AS mean + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."measured_at" < ${foldWindowStart} + GROUP BY m."type" + `, + ]); + + const preFoldByType = new Map< + string, + { count: number; min: number; max: number; mean: number } + >(); + for (const row of preFoldRemainder) { + preFoldByType.set(row.type, { + count: Number(row.count), + min: Number(row.min), + max: Number(row.max), + mean: Number(row.mean), + }); + } // v1.20.0 F6 — collapse the 90-day accumulator rows to the canonical // source per (type, day), then build a per-type accumulator-bucket list so @@ -653,12 +709,24 @@ async function computeFromRollups(userId: string): Promise { } const reg7 = composeWindowedRegression(accBuckets, since7); const reg30 = composeWindowedRegression(accBuckets, since30); + // v1.37.19 (A6-10) — splice the pre-fold remainder into the all-time + // figures: the rollup tier only covers the trailing fold window, so + // count / min / max / mean composed from it alone truncated "all + // time" to 5 y. Windowed fields (avg7/30, median, slopes) are + // unaffected — their windows sit far inside the fold window. + const pre = preFoldByType.get(row.type); + const allCount = row.count + (pre?.count ?? 0); + const allMin = pre ? Math.min(row.min, pre.min) : row.min; + const allMax = pre ? Math.max(row.max, pre.max) : row.max; + const allMean = pre + ? (row.mean * row.count + pre.mean * pre.count) / allCount + : row.mean; summaries[row.type] = { - count: row.count, + count: allCount, latest, - min: round2(row.min), - max: round2(row.max), - mean: round2(row.mean), + min: round2(allMin), + max: round2(allMax), + mean: round2(allMean), median: round2(narrow?.median ?? null), avg7: round2(narrow?.avg7 ?? null), avg30: round2(narrow?.avg30 ?? null), @@ -667,6 +735,31 @@ async function computeFromRollups(userId: string): Promise { avg30LastMonth: round2(narrow?.avg30_last_month ?? null), avg30LastYear: null, }; + if (pre) totalRows += pre.count; + } + + // v1.37.19 (A6-10) — a type whose ONLY rows are older than the fold + // window has no DAY bucket at all; without this arm it read as "never + // logged" on the rollup path while the live path reported its history. + for (const [type, pre] of preFoldByType) { + if (typesWithData.includes(type)) continue; + typeCount += 1; + totalRows += pre.count; + typesWithData.push(type); + summaries[type] = { + count: pre.count, + latest: latestByType.get(type) ?? null, + min: round2(pre.min), + max: round2(pre.max), + mean: round2(pre.mean), + median: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + avg30LastMonth: null, + avg30LastYear: null, + }; } // v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the diff --git a/src/lib/rollups/__tests__/tiered-context.test.ts b/src/lib/rollups/__tests__/tiered-context.test.ts index 44c576f6f..4998abc97 100644 --- a/src/lib/rollups/__tests__/tiered-context.test.ts +++ b/src/lib/rollups/__tests__/tiered-context.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ ensureUserRollupsFresh: vi.fn(), probeRollupCoverage: vi.fn(), measurementFindMany: vi.fn(), + queryRaw: vi.fn(), })); vi.mock("../measurement-rollups", () => ({ @@ -34,6 +35,8 @@ vi.mock("../measurement-coverage", () => ({ vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: mocks.measurementFindMany }, + // v1.37.19 (A6-11) — the live band fallback. + $queryRaw: mocks.queryRaw, }, })); @@ -70,6 +73,7 @@ describe("buildTieredSeries", () => { mocks.probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); mocks.measurementFindMany.mockResolvedValue([]); mocks.readRollupBuckets.mockResolvedValue([]); + mocks.queryRaw.mockResolvedValue([]); }); afterEach(() => { @@ -243,3 +247,97 @@ describe("buildTieredSeries", () => { }); }); }); + +// v1.37.19 (A6-11) — the live fallback the module doc promised from day +// one, and the far-from-UTC day-grain guard. +// +// Watched red: with the `readBandLive` fallback removed from `readBand` +// (the pre-fix reader returned the empty rollup result as-is), the +// coverage-miss case fails — a >5y account whose YEAR partition was never +// folded handed the AI empty MONTH/YEAR bands as if the history did not +// exist. With the `isNearUtc` guard removed, the far-tz case fails — the +// DAY band read UTC-keyed rollup buckets for a user whose local calendar +// disagrees with UTC for most of the day. +describe("buildTieredSeries — live fallback + near-UTC guard (A6-11)", () => { + beforeEach(() => { + mocks.ensureUserRollupsFresh.mockResolvedValue({ recomputed: false }); + mocks.probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); + mocks.measurementFindMany.mockResolvedValue([]); + mocks.readRollupBuckets.mockResolvedValue([]); + mocks.queryRaw.mockResolvedValue([]); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("serves an empty rollup band from the live aggregate instead of silence", async () => { + mocks.readRollupBuckets.mockResolvedValue([]); + mocks.queryRaw.mockResolvedValue([ + { + bucket_start: new Date("2025-01-01T00:00:00.000Z"), + count: 40, + mean: 82.1, + min_value: 78, + max_value: 90, + sd: 2.5, + }, + ]); + + const series = await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Europe/Berlin", + }); + + // Every band read fell through to the live aggregate (4 bands). + expect(mocks.queryRaw).toHaveBeenCalled(); + expect(series.yearBand.length).toBeGreaterThan(0); + expect(series.yearBand[0]).toMatchObject({ mean: 82.1, min: 78, max: 90 }); + }); + + it("keeps the rollup result when the band has buckets (no live read)", async () => { + mocks.readRollupBuckets.mockResolvedValue([ + rollupRow("2026-05-01T00:00:00.000Z", 80, 79, 81, 10), + ]); + + await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Europe/Berlin", + }); + expect(mocks.queryRaw).not.toHaveBeenCalled(); + }); + + it("routes the DAY band straight to the tz-keyed live path for far-from-UTC users", async () => { + await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Pacific/Auckland", + }); + + // The DAY grain never touched the UTC-keyed rollup tier … + const dayReads = mocks.readRollupBuckets.mock.calls.filter( + (c) => c[2] === "DAY", + ); + expect(dayReads).toEqual([]); + // … while the coarser bands still did. + const coarseReads = mocks.readRollupBuckets.mock.calls.map( + (c) => c[2] as RollupGranularity, + ); + expect(coarseReads).toEqual( + expect.arrayContaining(["WEEK", "MONTH", "YEAR"]), + ); + }); + + it("still reads the DAY rollup inside the near-UTC band", async () => { + mocks.readRollupBuckets.mockResolvedValue([ + rollupRow("2026-06-01T00:00:00.000Z", 80, 79, 81, 10), + ]); + await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Europe/Berlin", + }); + const dayReads = mocks.readRollupBuckets.mock.calls.filter( + (c) => c[2] === "DAY", + ); + expect(dayReads.length).toBe(1); + }); +}); diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts index 9d197f2b6..ac798e546 100644 --- a/src/lib/rollups/tiered-context.ts +++ b/src/lib/rollups/tiered-context.ts @@ -33,7 +33,7 @@ import type { MeasurementType, RollupGranularity, } from "@/generated/prisma/client"; -import { userDayKey } from "@/lib/tz/format"; +import { isNearUtc, userDayKey } from "@/lib/tz/format"; import { ensureUserRollupsFresh, readRollupBuckets, @@ -278,6 +278,116 @@ async function readRecentDaily( })); } +/** + * The `date_trunc` unit per band — a CLOSED map over the granularity enum, + * which is what makes splicing `unit` into the raw SQL below a + * whitelist-splice, not an injection surface. + */ +const BAND_TRUNC_UNIT: Record< + RollupGranularity, + "day" | "week" | "month" | "year" +> = { + DAY: "day", + WEEK: "week", + MONTH: "month", + YEAR: "year", +}; + +/** + * v1.37.19 (A6-11) — LIVE aggregate for one band, the fallback the module + * doc has promised since v1.18.7 but `readBand` never had: a coverage + * miss (or a >5y account whose YEAR partition was never folded) used to + * hand the AI an empty MONTH/YEAR band as if the history did not exist. + * + * One grouped aggregate over raw rows, shaped like a rollup bucket. Two + * documented approximations against the folded tier: no source-priority + * collapse (a dual-source day counts both readings into the mean), and + * no slope/r² (left null, exactly like a pre-0190 bucket). Both are + * acceptable for prompt context — the alternative was silence. + * + * `tzKey` (the far-from-UTC day-grain guard) buckets on the user's LOCAL + * calendar via `AT TIME ZONE`; parameter-bound, never spliced. + */ +async function readBandLive( + userId: string, + type: MeasurementType, + granularity: RollupGranularity, + from: Date, + to: Date, + tzKey?: string, +): Promise { + const unit = BAND_TRUNC_UNIT[granularity]; + try { + const rows = tzKey + ? await prisma.$queryRaw< + Array<{ + bucket_start: Date; + count: number; + mean: number; + min_value: number; + max_value: number; + sd: number | null; + }> + >` + SELECT + date_trunc(${unit}, m."measured_at" AT TIME ZONE ${tzKey}) AS bucket_start, + COUNT(*)::int AS count, + AVG(m."value")::double precision AS mean, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + STDDEV_POP(m."value")::double precision AS sd + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" = ${type}::"measurement_type" + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${from} + AND m."measured_at" < ${to} + GROUP BY 1 + ORDER BY 1 + ` + : await prisma.$queryRaw< + Array<{ + bucket_start: Date; + count: number; + mean: number; + min_value: number; + max_value: number; + sd: number | null; + }> + >` + SELECT + date_trunc(${unit}, m."measured_at") AS bucket_start, + COUNT(*)::int AS count, + AVG(m."value")::double precision AS mean, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + STDDEV_POP(m."value")::double precision AS sd + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" = ${type}::"measurement_type" + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${from} + AND m."measured_at" < ${to} + GROUP BY 1 + ORDER BY 1 + `; + return rows.map((r) => ({ + bucketStart: new Date(r.bucket_start), + count: Number(r.count), + mean: Number(r.mean), + minValue: Number(r.min_value), + maxValue: Number(r.max_value), + sd: r.sd === null ? null : Number(r.sd), + slope: null, + r2: null, + })); + } catch { + // The band builder never throws — an unreadable band degrades to + // empty exactly like the rollup path always has. + return []; + } +} + async function readBand( userId: string, type: MeasurementType, @@ -285,13 +395,28 @@ async function readBand( fromDaysAgo: number, toDaysAgo: number, now: number, + tz?: string, ): Promise { const from = new Date(now - fromDaysAgo * DAY_MS); const to = new Date(now - toDaysAgo * DAY_MS); + // v1.37.19 (A6-11) — the far-from-UTC guard the correlation readers + // carry (v1.4.38 W-A class): DAY rollup buckets group by UTC day, which + // diverges from the user's local calendar for most of the day outside + // the ±3 h near-UTC band. For those users the day-grain band goes + // straight to the live path keyed in their zone. The coarser bands stay + // on the UTC-anchored rollups — a few hours of offset moves bucket + // MEMBERSHIP at week/month/year grain by at most one edge reading. + if (granularity === "DAY" && tz && !isNearUtc(tz, new Date(now))) { + return readBandLive(userId, type, granularity, from, to, tz); + } // `readRollupBuckets` never throws — an empty / missing partition yields - // an empty array, which is the "fall back to nothing for this band" - // behaviour the coverage miss path relies on. - return readRollupBuckets(userId, type, granularity, from, to); + // an empty array. v1.37.19 — an empty band now falls back to the live + // aggregate instead of silently handing the AI nothing (the module doc + // promised replace-with-fallback from day one; only the "replace" half + // existed). + const buckets = await readRollupBuckets(userId, type, granularity, from, to); + if (buckets.length > 0) return buckets; + return readBandLive(userId, type, granularity, from, to); } /** @@ -331,6 +456,7 @@ export async function buildTieredSeries( TIERED_BANDS.dayUntil, TIERED_BANDS.rawDays, now, + options.tz, ), coarseOnly ? emptyBand