diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 2ea969fc0..39d0e3f0d 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -43,10 +43,12 @@ vi.mock("@/lib/db", () => ({ import { aggregateWmyBuckets, + pickRollupGranularityForWindow, readBestGranularityRollups, readTieredRollupSeries, type RollupBucketRow, } from "../measurement-read-wmy"; +import { pickBucket } from "@/lib/charts/bucket-time-series"; const { findMany } = mocks; @@ -70,6 +72,12 @@ function bucket( beforeEach(() => { findMany.mockReset(); + // Default to "no coverage" for any read a test did not queue explicitly — + // the v1.37.29 span-refinement probe in `readTieredRollupSeries` issues an + // extra read when a fixture's rows span less than the requested window, + // and an unqueued vi.fn() would resolve `undefined` and crash the reader + // instead of modelling an unminted tier. + findMany.mockResolvedValue([]); mocks.userFindUnique.mockReset(); mocks.userFindUnique.mockResolvedValue({ sourcePriorityJson: null }); }); @@ -454,4 +462,120 @@ describe("readTieredRollupSeries", () => { expect(result?.rows[0].minValue).toBe(78); expect(result?.rows[0].maxValue).toBe(85); }); + + // v1.37.29 — the tier keys off the ACTUAL data span, not the requested + // window width. The "All" tab always requests ~3650 days, so pre-fix a + // record whose history spans four months came back as four MONTH means + // while the chart captioned them from the real span — "weekly average" + // over monthly buckets. + describe("span refinement", () => { + it("re-reads at the tier the real span calls for (4 months of data on an All request → DAY)", async () => { + const months = [ + bucket("2026-02-01T00:00:00.000Z"), + bucket("2026-03-01T00:00:00.000Z"), + bucket("2026-04-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + const days = [ + bucket("2026-02-03T00:00:00.000Z"), + bucket("2026-03-14T00:00:00.000Z"), + bucket("2026-04-09T00:00:00.000Z"), + bucket("2026-05-28T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(days); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + // Span of the MONTH rows ≈ 89 days → the display ladder wants DAY, + // and the refinement jumps straight there (no WEEK stop-over). + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + expect(findMany.mock.calls[1][0].where.granularity).toBe("DAY"); + expect(findMany).toHaveBeenCalledTimes(2); + expect(result?.granularity).toBe("DAY"); + expect(result?.rows).toHaveLength(4); + expect(result?.rows[0].measuredAt).toBe("2026-02-03T00:00:00.000Z"); + }); + + it("refines to WEEK for a span between one and two years", async () => { + const months = [ + bucket("2025-01-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + const weeks = [ + bucket("2024-12-30T00:00:00.000Z"), + bucket("2026-04-27T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(weeks); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK"); + expect(result?.granularity).toBe("WEEK"); + }); + + it("keeps the coarser rows when the finer tier has no coverage", async () => { + const months = [ + bucket("2026-01-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months).mockResolvedValueOnce([]); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + expect(result?.granularity).toBe("MONTH"); + expect(result?.rows).toHaveLength(2); + }); + + it("does not probe when the rows already span the requested tier", async () => { + const months = [ + bucket("2018-01-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + expect(findMany).toHaveBeenCalledTimes(1); + expect(result?.granularity).toBe("MONTH"); + }); + }); +}); + +// v1.37.29 — the server tier ladder and the client caption ladder +// (`pickBucket`) are DIFFERENT functions with different thresholds, and +// their divergence is exactly what shipped monthly means under a +// "weekly average" chip. The invariant that keeps the caption honest is +// directional: for any data span, the tier the server serves is never +// COARSER than the bucket the client captions — the client may fold +// finer rows up to its caption, but it cannot split coarser ones. +describe("server tier vs client caption ladder", () => { + const SERVER_COARSENESS = { DAY: 0, WEEK: 1, MONTH: 2, YEAR: 3 } as const; + const CLIENT_COARSENESS = { day: 0, week: 1, month: 2 } as const; + + it("never serves coarser than the client captions, for every span up to ten years", () => { + for (let span = 1; span <= 3650; span++) { + const server = pickRollupGranularityForWindow(span); + const client = pickBucket(span); + expect( + SERVER_COARSENESS[server], + `span ${span}d: server ${server} vs client caption ${client}`, + ).toBeLessThanOrEqual(CLIENT_COARSENESS[client]); + } + }); }); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 779adf443..5121ce427 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -303,20 +303,28 @@ export interface TieredSeriesRow { } /** - * Pick the rollup granularity that matches the chart's own client-side - * `bucketTimeSeries` downsampler (`src/lib/charts/bucket-time-series.ts`): + * Pick the rollup granularity for a chart window of `windowDays`: * - * - > 730 days → MONTH (the chart renders month points) - * - 366–730 → WEEK (the chart renders week points) + * - > 730 days → MONTH + * - 366–730 → WEEK * - ≤ 365 → DAY (daily resolution; the DAY cap covers it) * - * Mirroring the display tier means the server returns exactly the - * resolution the chart will paint — no finer (which would truncate at the - * DAY cap) and no coarser (which would drop visible detail). The finer - * fallback in `readTieredRollupSeries` still rescues a tenant whose - * coarse buckets the worker has not minted yet. + * This ladder must stay COMPATIBLE with the chart's client-side + * `pickBucket` (`src/lib/charts/bucket-time-series.ts`), which captions + * the rendered series from the visible data span (day ≤ 90, week 91–730, + * month > 730): for any span the tier chosen here is never COARSER than + * the caption the client will print, so a "Wochendurchschnitt" chip + * always sits over points the client actually folded into weeks. The + * ladders are not identical — this one may be finer (DAY rows for a + * 91–365 d span; the client folds them into the weeks it captions) — + * and a test pins the never-coarser direction across every span. + * + * The finer fallback in `readTieredRollupSeries` still rescues a tenant + * whose coarse buckets the worker has not minted yet. */ -function pickRollupGranularityForWindow(windowDays: number): RollupGranularity { +export function pickRollupGranularityForWindow( + windowDays: number, +): RollupGranularity { if (windowDays > 730) return "MONTH"; if (windowDays > 365) return "WEEK"; return "DAY"; @@ -393,8 +401,8 @@ export async function readTieredRollupSeries(opts: { // drop detail the chart can render). Stop at the first tier with coverage. const targetIdx = TIER_ORDER.indexOf(target); for (let i = targetIdx; i >= 0; i--) { - const granularity = TIER_ORDER[i]; - const rows = await readGranularity( + let granularity = TIER_ORDER[i]; + let rows = await readGranularity( userId, type, granularity, @@ -403,6 +411,39 @@ export async function readTieredRollupSeries(opts: { priority, ); if (rows && rows.length > 0) { + // Refine the tier by the ACTUAL data span, not the requested window. + // The "Alle" tab always requests ~3650 days, so keying the tier off + // the request width alone handed EVERY account MONTH buckets — a + // record whose history spans four months came back as four monthly + // means while the client (which captions from the real span) labelled + // them "weekly average". Re-reading at the tier the real span calls + // for returns the resolution the chart will actually caption. A + // coverage miss on the finer tier keeps the coarser rows — finer + // tiers are the base tiers the workers mint first, so in practice + // this probe only fires when the refinement is genuinely available. + // The walk is strictly finer-only, so it terminates. + for (;;) { + const spanDays = Math.ceil( + (rows[rows.length - 1].bucketStart.getTime() - + rows[0].bucketStart.getTime()) / + 86_400_000, + ); + const refined = pickRollupGranularityForWindow(Math.max(1, spanDays)); + if (TIER_ORDER.indexOf(refined) >= TIER_ORDER.indexOf(granularity)) { + break; + } + const finerRows = await readGranularity( + userId, + type, + refined, + from, + to, + priority, + ); + if (!finerRows || finerRows.length === 0) break; + granularity = refined; + rows = finerRows; + } const useSum = CUMULATIVE_HK_TYPES.has(type); annotate({ action: { name: "measurement.list" },