From e51e81c76f96a57ef9bf983cac596f2bdaa15d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 06:44:53 +0200 Subject: [PATCH] fix(measurements): flag the oldest groupBy=day bucket as partial when the cap hits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw-row read backing the day-collapsed list is desc + take(limit). On a dense account whose in-window row count crosses the cap, the oldest returned row sits mid-day — every earlier sample on that same calendar day was cut off, so its bucket's SUM silently understates the real day total with no marker. Flags exactly that one bucket partial: true when the cap was actually hit, so a page that came in under the cap (the whole window covered) carries no flag on any bucket. --- .../__tests__/group-by-day.test.ts | 93 +++++++++++++++++++ src/app/api/measurements/route.ts | 21 +++++ 2 files changed, 114 insertions(+) diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index d22a10677..9255e6719 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -247,6 +247,99 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { expect(json.data.meta.droppedDuplicates).toBe(1); }); + // v1.30.3 (QA F6) — a capped read's oldest bucket sits mid-day: every + // sample on that calendar day BEFORE the cutoff row was excluded, so the + // bucket's SUM understates the real day total. The row must carry a + // `partial: true` marker instead of rendering as if it were complete. + it("flags the oldest bucket partial when the raw-row read hits the cap", async () => { + // limit=3: the DESC-ordered read returns exactly 3 rows (the cap), so + // the oldest of the three (2026-05-14) is a truncated day — its bucket + // could be missing earlier same-day samples the cap cut off. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-1", + type: "ACTIVITY_STEPS", + value: 3400, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T11:00:00.000Z"), + notes: null, + }, + { + id: "m-2", + type: "ACTIVITY_STEPS", + value: 2500, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T07:00:00.000Z"), + notes: null, + }, + { + id: "m-3", + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-14T16:00:00.000Z"), + notes: null, + }, + ] as never); + + const res = await GET( + getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=3"), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ + dayKey: string; + value: number; + partial?: boolean; + }>; + meta: { capped?: boolean }; + }; + }; + const day15 = json.data.measurements.find((m) => m.dayKey === "2026-05-15"); + const day14 = json.data.measurements.find((m) => m.dayKey === "2026-05-14"); + expect(day15?.partial).toBeUndefined(); + expect(day14?.partial).toBe(true); + }); + + it("flags no bucket partial when the read comes in under the cap (whole window covered)", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-1", + type: "ACTIVITY_STEPS", + value: 3400, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T11:00:00.000Z"), + notes: null, + }, + { + id: "m-3", + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-14T16:00:00.000Z"), + notes: null, + }, + ] as never); + + // Default limit (100) is nowhere near hit by 2 rows. + const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day")); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ dayKey: string; partial?: boolean }>; + }; + }; + expect(json.data.measurements.every((m) => m.partial === undefined)).toBe( + true, + ); + }); + it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => { vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6aaf88a23..3d64bb0f8 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -300,6 +300,23 @@ export const GET = apiHandler(async (request: NextRequest) => { } } + // v1.30.3 (QA F6) — the read above is DESC + take(limit), so once a + // dense account's in-window row count crosses the cap, the OLDEST + // returned row sits mid-day: every earlier sample on that same + // calendar day was cut off, and its bucket's SUM understates the real + // day total with no marker. Flag exactly that one bucket — the day + // containing the cutoff row (the minimum dayKey among the buckets + // actually built) — `partial: true` only when the cap was truly hit; + // a read that came in under the cap covers its whole window and no + // day is a truncation artifact. + const cappedRead = rows.length === limit; + let oldestDayKey: string | null = null; + if (cappedRead) { + for (const key of buckets.keys()) { + if (oldestDayKey === null || key < oldestDayKey) oldestDayKey = key; + } + } + const measurements = Array.from(buckets.values()) .map((b) => ({ // Synthetic id so the list-row key stays stable across pages @@ -316,6 +333,7 @@ export const GET = apiHandler(async (request: NextRequest) => { notes: null, dayKey: b.dayKey, sampleCount: b.sampleCount, + ...(b.dayKey === oldestDayKey ? { partial: true } : {}), })) .sort((a, b) => { const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0; @@ -330,6 +348,9 @@ export const GET = apiHandler(async (request: NextRequest) => { groupBy: "day", rowsScanned: rows.length, droppedDuplicates: rows.length - canonicalRows.length, + // QA F6 — surfaces when the oldest bucket in this page is a + // partial-day sum (the read hit the cap). + capped: cappedRead, }, }); return apiSuccess({