From 8144281d1e601afd10265107ce9dfba4a1a3266b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 02:33:52 +0200 Subject: [PATCH] fix(charts): keep all-time charts full-history via monthly aggregation The "All time" range tab now resolves to a monthly aggregation across the user's full measurement history (or weekly when total history sits under two years) instead of silently narrowing to a trailing 365-day window. The route's `BUCKET_CAP` ceiling bounds the series at 24 monthly buckets so a multi-decade account stays payload-flat without losing the "earliest reading to today" framing. --- .../__tests__/range-aggregation-route.test.ts | 64 +++++++++++++++++++ src/app/api/measurements/route.ts | 8 +++ 2 files changed, 72 insertions(+) diff --git a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts index 8e80c8467..5783fe231 100644 --- a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts +++ b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts @@ -152,3 +152,67 @@ describe("GET /api/measurements — aggregation gate (C2)", () => { expect(json.data.meta.aggregate).toBe("monthly"); }); }); + +describe("GET /api/measurements — all-time semantics (SD-H1)", () => { + it("returns a sensible monthly series for a multi-year all-time window", async () => { + // Simulate four years of monthly weight rollups. The fixture mirrors + // what `date_trunc('month', measured_at)` produces server-side and + // proves the route hands back the full history (capped at the + // monthly BUCKET_CAP of 24 — the chart paints the most recent 24 + // months when "All time" exceeds the cap). + const buckets = Array.from({ length: 24 }, (_, i) => ({ + type: "WEIGHT", + bucket_start: new Date(Date.UTC(2024, i, 1, 0, 0, 0)), + avg: 80 - i * 0.05, + cnt: 30, + })); + vi.mocked(prisma.$queryRaw).mockResolvedValue(buckets as never); + + // "All time" client call: oldest measurement → today, monthly grain. + const res = await GET( + getRequest( + "type=WEIGHT&from=2020-01-01T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=monthly&limit=5000", + ), + ); + + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ measuredAt: string; count: number }>; + meta: { aggregate?: string; total: number }; + }; + }; + // The response is monthly-bucketed (NOT a truncated 365-day slice). + expect(json.data.meta.aggregate).toBe("monthly"); + expect(json.data.measurements.length).toBe(24); + // Buckets cover distinct calendar months — a 365-day slice could + // not span more than 13 months. + const months = new Set( + json.data.measurements.map((m) => m.measuredAt.slice(0, 7)), + ); + expect(months.size).toBe(24); + }); + + it("returns a weekly series when the all-time window is under two years", async () => { + // Three months past one year → falls in the weekly grain band. + const weeks = Array.from({ length: 60 }, (_, i) => ({ + type: "PULSE", + bucket_start: new Date(Date.UTC(2025, 0, 6 + i * 7, 0, 0, 0)), + avg: 60 + (i % 5), + cnt: 7 * 24 * 60, + })); + vi.mocked(prisma.$queryRaw).mockResolvedValue(weeks as never); + + const res = await GET( + getRequest( + "type=PULSE&from=2024-12-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=weekly&limit=5000", + ), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { meta: { aggregate?: string }; measurements: Array }; + }; + expect(json.data.meta.aggregate).toBe("weekly"); + expect(json.data.measurements.length).toBe(60); + }); +}); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 0d8055e79..5e0d6f377 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -63,6 +63,14 @@ export const GET = apiHandler(async (request: NextRequest) => { // applied AFTER, so a 1-year `aggregate=daily` window walks every // row in the window and returns up to 365 buckets per type instead // of truncating to the first N raw rows. + // + // SD-H1 — "All time" range semantics. When the chart's "All" tab + // is active, the client passes `from` = the user's earliest + // measurement (or a sentinel like 1970-01-01) plus `to` = now plus + // `aggregate=monthly` (or `weekly` when full history < 2 years). + // The route's response is bounded by the `BUCKET_CAP` ceiling per + // grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade + // account never paints an unbounded series. if (aggregate && aggregate !== "raw" && from && to) { const grain: AggregateGrain = aggregate; const cap = Math.min(limit, BUCKET_CAP[grain]);