mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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([
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user