diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f1d4e182f..160cc6193 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4204,8 +4204,9 @@ paths: With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way - over nights. On both, `meta.total` counts the days or nights in the filtered window, and a page holds at most - 1000 of them (`meta.limit` echoes the value applied). + over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes + `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the + value applied. parameters: - in: query name: type diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6939d4777..1efeef64c 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -55,7 +55,7 @@ import { pickMainNightAndNaps, SESSION_GAP_MS, } from "@/lib/analytics/sleep-night"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver"; import { isNamedSleepStageBody, sleepStageEntrySchema, @@ -79,7 +79,6 @@ import { type GlucoseContext, type SleepStage, } from "@/generated/prisma/client"; -import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "measurements"); @@ -193,10 +192,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // hid. Gated to cumulative HK types — the spot-sample types render // one-row-per-event already and have nothing to drill into. if (dayKey && type && CUMULATIVE_HK_TYPES.has(type as MeasurementType)) { - const tz = - user.timezone && user.timezone.length > 0 - ? user.timezone - : DEFAULT_TIMEZONE; + // The same zone the day-grouped list cuts its days in, so a row's + // drill-down opens exactly the day the row summed. + const tz = await resolveStoredTimezone(user.timezone); // Resolve `[dayStart, dayEnd)` for the requested calendar day in // the user's local zone. v1.4.37 W10 — `localDayWindow` honours // DST: the previous shape (`canonicalDailyTimestamp ± 12 h`) @@ -408,13 +406,10 @@ async function dayGroupedListResponse( }, ) { const { type, limit, offset, sortDir } = opts; - const stored = - user.timezone && user.timezone.length > 0 - ? user.timezone - : DEFAULT_TIMEZONE; - // A zone Postgres does not know would 500 the read; the drill-down cuts - // days in the same zone, so both must agree on it. - const tz = isValidTimezone(stored) ? stored : DEFAULT_TIMEZONE; + // One resolver for the stored zone, shared with the drill-down and the + // sleep list: a usable IANA name or the instance default, never a value + // Postgres would refuse. The drill-down must cut the same days. + const tz = await resolveStoredTimezone(user.timezone); // The request's filters, as SQL: the same ones the plain list applies. const filters = Prisma.sql` @@ -623,7 +618,7 @@ const SLEEP_NIGHT_PAGE_MAX = 1000; * there without saying so to the client. */ async function sleepListResponse( - user: { id: string }, + user: { id: string; timezone?: string | null }, opts: { from?: Date; to?: Date; @@ -636,7 +631,7 @@ async function sleepListResponse( const { from, to, offset, sortDir, dayKey } = opts; const limit = Math.min(opts.limit, SLEEP_NIGHT_PAGE_MAX); const [tz, priorityJson] = await Promise.all([ - resolveUserTimezone(user.id), + resolveStoredTimezone(user.timezone), loadUserSourcePriority(user.id), ]); @@ -647,7 +642,6 @@ async function sleepListResponse( ...(dayKey ? { night: dayKey, limit: 1, offset: 0 } : { limit, offset }), }); const nightKeys = page.nights.map((n) => n.night); - const wanted = new Set(nightKeys); // The page's sessions lie between the first one's start and the last one's // end, and every other session is more than the session gap away from that @@ -692,8 +686,23 @@ async function sleepListResponse( }, }); } - const sessions = reconstructSleepSessions(rows, tz, priorityJson).filter( - (s) => wanted.has(s.night), + // Each reconstructed session is filed under the page night whose span + // holds it, and takes that night's label. Matching by the reconstruction's + // own label lost a night whenever the two named different days: the page + // counted it under one, the reconstruction filed it under the other, and + // the filter dropped it from both. + const sessions = reconstructSleepSessions(rows, tz, priorityJson).flatMap( + (session) => { + // A second of slack absorbs the sub-millisecond rounding between the + // two start computations; sessions sit hours apart, so it can never + // reach a neighbour. + const home = page.nights.find( + (n) => + n.spanStart.getTime() - 1000 <= session.start.getTime() && + session.end.getTime() <= n.spanEnd.getTime() + 1000, + ); + return home ? [{ ...session, night: home.night }] : []; + }, ); // Drill-down — return the requested night's canonical stage segments. @@ -812,11 +821,24 @@ async function sleepListResponse( * filtered set from the same query. `spanStart` / `spanEnd` bound the * night's counting sessions, so the caller reads exactly their rows. * - * The session's night here is taken over every writer, while the JS fold - * takes it over the winning writer only. They part only when a losing writer - * runs past midnight and the winner does not; that night then sits on the - * page and comes back without a row, and `total` counts it. + * This label is the list's own: the page is cut by it, a row carries it and + * a drill-down asks by it. It is taken over every writer, where the JS + * reconstruction takes the winning writer only, so the two can name + * different days (a losing phone's in-bed span running past midnight while + * the watch stops before it). The caller therefore never matches the two by + * label: it files each reconstructed session under the night whose span + * holds it, which is exact, because a reconstructed session is one of these + * sessions narrowed to its winner. + * + * With `night` set (a drill-down) the scan is cut to a window of 36 hours + * either side of that local day. Sessions are split at a three-hour gap, so + * a session ending on that day starts inside the window unless it lasted + * over a day and a half, and every session the cut truncates ends on another + * day. */ +/** How far a drill-down's session scan reaches either side of its day. */ +const DRILL_DOWN_REACH_MS = 36 * 60 * 60 * 1000; + async function sleepNightPage( userId: string, tz: string, @@ -832,8 +854,8 @@ async function sleepNightPage( nights: Array<{ night: string; spanStart: Date; spanEnd: Date }>; total: number; }> { - const zone = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; const gapSeconds = SESSION_GAP_MS / 1000; + const window = opts.night ? localDayWindow(opts.night, tz) : null; // Direction comes from the Zod enum, never from raw input. const direction = Prisma.raw(opts.sortDir === "asc" ? "ASC" : "DESC"); const nightsCte = Prisma.sql` @@ -849,6 +871,12 @@ async function sleepNightPage( AND m."deleted_at" IS NULL ${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty} ${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty} + ${ + window + ? Prisma.sql`AND m."measured_at" >= ${new Date(window.dayStart.getTime() - DRILL_DOWN_REACH_MS)} + AND m."measured_at" < ${new Date(window.dayEnd.getTime() + DRILL_DOWN_REACH_MS)}` + : Prisma.empty + } ), o AS ( SELECT r.*, MAX(e) OVER ( ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING @@ -867,7 +895,7 @@ async function sleepNightPage( HAVING BOOL_OR(asleep) ), nights AS ( SELECT - to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${zone}, 'YYYY-MM-DD') AS night, + to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD') AS night, MIN(s) AS span_start, MAX(e) AS span_end FROM sess diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index b435f3850..35e46830c 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -804,7 +804,7 @@ export const measurementPaths: NonNullable = { summary: "List measurements", description: "Filter by type + date range. Response is paged via offset/limit; default limit 100.\n\n" + - "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights. On both, `meta.total` counts the days or nights in the filtered window, and a page holds at most 1000 of them (`meta.limit` echoes the value applied).", + "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.", requestParams: { query: listMeasurementsSchema, }, diff --git a/tests/integration/measurements-day-grouped-paging.test.ts b/tests/integration/measurements-day-grouped-paging.test.ts index 85d60a3a9..738a86f6d 100644 --- a/tests/integration/measurements-day-grouped-paging.test.ts +++ b/tests/integration/measurements-day-grouped-paging.test.ts @@ -88,7 +88,10 @@ async function seedSession(username: string) { }, }); const session = await prisma.session.create({ - data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + // An hour, not a minute: walking every page of a long history can + // outlast a minute on a loaded runner, and an expired session reads as + // a 401 halfway through. + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); return user; @@ -207,7 +210,7 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => { ).json()) as ListEnvelope; expect(past.data.measurements).toEqual([]); expect(past.data.meta.total).toBe(expected.size); - }); + }, 240_000); it("starts at the oldest day ascending and counts only the filtered window", async () => { const user = await seedSession("day-paging-window-user"); @@ -235,5 +238,5 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => { } expect(windowed.data.meta.total).toBe(inWindow); expect(windowed.data.measurements).toHaveLength(PAGE); - }); + }, 240_000); }); diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts index 2044d7d74..5db0eaf56 100644 --- a/tests/integration/measurements-sleep-list.test.ts +++ b/tests/integration/measurements-sleep-list.test.ts @@ -66,7 +66,10 @@ async function seedSession(username: string) { data: { username, email: `${username}@example.test`, role: "USER" }, }); const session = await prisma.session.create({ - data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + // An hour, not a minute: walking every page of a long history can + // outlast a minute on a loaded runner, and an expired session reads as + // a 401 halfway through. + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); return user; @@ -287,5 +290,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { ).json()) as ListEnvelope; expect(drill.data.measurements).toHaveLength(1); expect(drill.data.measurements[0].sleepStage).toBe("CORE"); + }, 240_000); + + // Review finding on the SQL paging: the page labels a session by the last + // row of ANY writer, the reconstruction by its winning writer. A watch that + // stops before midnight beside a phone whose in-bed span runs past it gave + // two different days, and matching by label dropped the night from both. + it("keeps a night whose losing writer runs past midnight", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("sleep-midnight-user"); + await prisma.user.update({ + where: { id: user.id }, + data: { timezone: "UTC" }, + }); + const base = { + userId: user.id, + type: "SLEEP_DURATION" as const, + unit: "minutes", + source: "APPLE_HEALTH" as const, + }; + await prisma.measurement.createMany({ + data: [ + // The watch wins the device ladder and ends at 23:50. + { + ...base, + value: 420, + measuredAt: new Date("2026-03-10T23:50:00.000Z"), + externalId: "uuid-watch-core", + sleepStage: "CORE" as const, + deviceType: "watch", + }, + // The phone's in-bed span ends at 00:20 the next day. + { + ...base, + value: 450, + measuredAt: new Date("2026-03-11T00:20:00.000Z"), + externalId: "uuid-phone-inbed", + sleepStage: "IN_BED" as const, + deviceType: "phone", + }, + ], + }); + + const list = (await ( + await callList("type=SLEEP_DURATION") + ).json()) as ListEnvelope; + expect(list.data.meta.total).toBe(1); + // The night is listed, not lost between two labels. + expect(list.data.measurements).toHaveLength(1); + const [night] = list.data.measurements; + expect(night.value).toBe(420); + + // Its drill-down, asked by the label the list gave it, finds it too. + const drill = (await ( + await callList(`type=SLEEP_DURATION&dayKey=${night.dayKey}`) + ).json()) as ListEnvelope; + expect(drill.data.measurements.map((m) => m.sleepStage)).toEqual(["CORE"]); }); });