fix(measurements): bound the per-night sleep list to a year ending at to

The sleep list capped its read to the last year only when no `from`
was sent. An explicit early `from` walked every per-stage row back to
that date, on a multi-source account several rows per night for years.

The read window is now at most a year ending at `to` (or now). `from`
narrows it but never widens it, older nights are reached by moving
`to` back, and `meta.windowFrom` names the instant a cut window
started, so a client can tell a cut from an empty history.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 08:58:42 +02:00
1 parent 730e9e7015
commit 5f1eb05445
4 files changed
+89 -5

No files matched your search

+6
View File
@@ -41340,6 +41340,12 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
windowFrom:
description: "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant
the read started from. The window is a year ending at `to` (or now); page further back by moving `to`."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- total
- limit
+19 -4
View File
@@ -543,9 +543,18 @@ async function sleepListResponse(
loadUserSourcePriority(user.id),
]);
// Bound the read window: honour an explicit from/to, otherwise cap to
// the last year so the per-stage scan stays small.
const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000);
// Bound the read window to a year ending at `to` (or now). An explicit
// `from` narrows it but never widens it: an early `from` used to walk every
// per-stage row back to that date, which on a multi-year, multi-source
// account is the unbounded read this cap exists to stop. Older nights are
// reached by moving `to` back, and `meta.windowFrom` says where a cut
// window started so a client can tell a cut from an empty history.
const windowEnd = to ?? new Date();
const floor = new Date(
windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000,
);
const since = from && from > floor ? from : floor;
const windowCut = from !== undefined && from < floor;
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
@@ -690,7 +699,13 @@ async function sleepListResponse(
});
return apiSuccess({
measurements,
meta: { total: totalNights, limit, offset, groupBy: "night" },
meta: {
total: totalNights,
limit,
offset,
groupBy: "night",
...(windowCut ? { windowFrom: since.toISOString() } : {}),
},
});
}
+6
View File
@@ -389,6 +389,12 @@ const listMeasurementsMeta = z
.describe(
"Rows discarded by the cross-source canonical picker on the day-collapse mode.",
),
windowFrom: z.iso
.datetime({ offset: true })
.optional()
.describe(
"Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant the read started from. The window is a year ending at `to` (or now); page further back by moving `to`.",
),
})
.meta({ id: "ListMeasurementsMeta" });
@@ -56,7 +56,7 @@ interface ListEnvelope {
napCount?: number;
awakenings?: number;
}>;
meta: { total: number; groupBy?: string };
meta: { total: number; groupBy?: string; windowFrom?: string };
};
}
@@ -160,4 +160,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => {
const stages = body.data.measurements.map((m) => m.sleepStage).sort();
expect(stages).toEqual(["CORE", "DEEP", "REM"]);
});
it("reads at most a year ending at `to`, however early `from` is", async () => {
// An explicit early `from` used to walk every per-stage row back to it.
const prisma = getPrismaClient();
const user = await seedSession("sleep-window-user");
const day = 86_400_000;
const morning = new Date();
morning.setUTCHours(4, 0, 0, 0);
// One night of a single CORE stage every day for two years.
await prisma.measurement.createMany({
data: Array.from({ length: 730 }, (_, i) => ({
userId: user.id,
type: "SLEEP_DURATION" as const,
value: 420,
unit: "minutes",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(morning.getTime() - i * day),
externalId: `uuid-night-${i}`,
sleepStage: "CORE" as const,
})),
});
const spy = vi.spyOn(prisma.measurement, "findMany");
const from = new Date(morning.getTime() - 800 * day).toISOString();
const res = await callList(
`type=SLEEP_DURATION&from=${encodeURIComponent(from)}&limit=1000`,
);
expect(res.status).toBe(200);
const body = (await res.json()) as ListEnvelope;
expect(body.data.meta.total).toBeLessThanOrEqual(366);
expect(body.data.meta.total).toBeGreaterThanOrEqual(364);
expect(body.data.meta.windowFrom).toBeDefined();
const windowFrom = Date.parse(body.data.meta.windowFrom!);
expect(Date.now() - windowFrom).toBeLessThan(366 * day);
expect(Date.now() - windowFrom).toBeGreaterThan(364 * day);
// The read itself was bounded, not just the answer.
const read = spy.mock.calls
.map(([args]) => args as { where?: { measuredAt?: { gte?: Date } } })
.find((args) => args?.where?.measuredAt?.gte);
expect(read?.where?.measuredAt?.gte?.getTime()).toBe(windowFrom);
// Older nights are reached by moving `to` back.
const to = new Date(morning.getTime() - 400 * day).toISOString();
const older = await callList(
`type=SLEEP_DURATION&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=1000`,
);
const olderBody = (await older.json()) as ListEnvelope;
expect(olderBody.data.meta.total).toBeGreaterThanOrEqual(329);
expect(olderBody.data.meta.total).toBeLessThanOrEqual(331);
// A window inside the year is untouched and carries no marker.
const recent = await callList(
`type=SLEEP_DURATION&from=${encodeURIComponent(new Date(morning.getTime() - 10.5 * day).toISOString())}`,
);
const recentBody = (await recent.json()) as ListEnvelope;
expect(recentBody.data.meta.total).toBe(11);
expect(recentBody.data.meta.windowFrom).toBeUndefined();
});
});