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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 02:33:52 +02:00
1 parent 025c88859c
commit 8144281d1e
2 files changed
+72

No files matched your search

@@ -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<unknown> };
};
expect(json.data.meta.aggregate).toBe("weekly");
expect(json.data.measurements.length).toBe(60);
});
});
+8
View File
@@ -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]);