diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ad690c61f..046d46b73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ffcaafece..278ddb899 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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() } : {}), + }, }); } diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 864baeb4a..dea56ced2 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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" }); diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts index 9e952c4dc..9524bd854 100644 --- a/tests/integration/measurements-sleep-list.test.ts +++ b/tests/integration/measurements-sleep-list.test.ts @@ -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(); + }); });