fix(measurements): flag the oldest groupBy=day bucket as partial when the cap hits

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 06:47:32 +02:00
1 parent dfa4533c7c
commit e51e81c76f
2 files changed
+114

No files matched your search

@@ -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([
{
+21
View File
@@ -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({