diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 936f20730..bfca576c4 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -213,7 +213,33 @@ describe("readBestGranularityRollups", () => { it("returns null when the window holds no buckets", async () => { const result = await readBestGranularityRollups("user", "WEIGHT", 1095); expect(result).toBeNull(); - expect(queryRaw).toHaveBeenCalledTimes(1); + // YEAR, MONTH and WEEK folds, then the DAY tier. + expect(queryRaw).toHaveBeenCalledTimes(3); + expect(findMany).toHaveBeenCalledTimes(1); + }); + + it("steps one tier finer when the data sits only in the leading partial bucket", async () => { + // A 731-day window on 2026-09-26 starts 2024-09-25. October to December + // 2024 fold into the YEAR bucket 2024-01-01, which starts before the + // window and is left out; the MONTH tier starts inside it. + vi.setSystemTime(new Date("2026-09-26T12:00:00.000Z")); + const readings: Array<[string, number]> = [ + ["2024-10-05", 80], + ["2024-11-05", 81], + ["2024-12-05", 82], + ]; + queryRaw + .mockResolvedValueOnce([folded("2024-01-01T00:00:00.000Z", readings)]) + .mockResolvedValueOnce( + readings.map(([day, value]) => + folded(`${day.slice(0, 7)}-01T00:00:00.000Z`, [[day, value]]), + ), + ); + const result = await readBestGranularityRollups("user", "WEIGHT", 731); + expect(result?.granularity).toBe("MONTH"); + expect(result?.rows.map((r) => r.mean)).toEqual([80, 81, 82]); + expect(sqlOf(queryRaw.mock.calls[0])).toContain("'year'"); + expect(sqlOf(queryRaw.mock.calls[1])).toContain("'month'"); }); it("composes a coarse bucket's mean, spread and slope from its days", async () => { diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index ece63f589..d40941945 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -125,25 +125,32 @@ export async function readBestGranularityRollups( userPriorityJson !== undefined ? userPriorityJson : await loadUserSourcePriority(userId); - // Every tier is folded from the same canonical DAY buckets, so a tier - // either has coverage or none has: pick the coarsest floor the window - // clears and read it once. - const floor = GRANULARITY_FLOORS.find((f) => windowDays >= f.minWindowDays); - if (!floor) return null; - const rows = await readGranularity( - userId, - type, - floor.granularity, - since, - // Trailing-window semantics: no upper bound. This router serves the - // "last N days to now" probes (summaries-slice / health-score); the - // requested-window bounding lives on `readTieredRollupSeries`. - null, - priority, + // Every tier is folded from the same canonical DAY buckets, but a coarse + // bucket that starts before `since` is left out (it holds only part of its + // days). Data that sits only in that leading partial bucket, say three + // months at the start of a 731-day window, reads as empty at YEAR: step one + // tier finer until a tier has buckets, down to DAY, which starts inside the + // window by construction. + const floors = GRANULARITY_FLOORS.filter( + (f) => windowDays >= f.minWindowDays, ); - return rows && rows.length > 0 - ? { granularity: floor.granularity, rows } - : null; + for (const floor of floors) { + const rows = await readGranularity( + userId, + type, + floor.granularity, + since, + // Trailing-window semantics: no upper bound. This router serves the + // "last N days to now" probes (summaries-slice / health-score); the + // requested-window bounding lives on `readTieredRollupSeries`. + null, + priority, + ); + if (rows && rows.length > 0) { + return { granularity: floor.granularity, rows }; + } + } + return null; } /**