From b59f8118a959c9c0f81d7a72b5a4eb886d095bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:40:08 +0200 Subject: [PATCH] fix(sleep): round night minute totals to whole numbers `GET /api/sleep/night` summed second-precision stage rows into asleep / awake totals and serialised them unrounded (e.g. 433.4999), which also violated the OpenAPI integer contract. Round the present minute values; `inBedMinutes` / `awakeMinutes` stay null when the night never saw that stage. --- .../api/sleep/night/__tests__/route.test.ts | 18 ++++++++++++++++++ src/app/api/sleep/night/route.ts | 10 +++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index 509e7854a..aef6583ef 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -103,6 +103,24 @@ describe("GET /api/sleep/night", () => { expect(core.end).toBe("2026-06-04T01:00:00.000Z"); }); + it("rounds asleep / awake minutes to whole numbers", async () => { + // iOS #18 — `asleepMinutesOf` / awake totals sum raw second-precision + // minute values, so a night sums to e.g. 433.4999; the serializer must + // emit whole minutes. CORE+DEEP = 433.4999 → 433; AWAKE 12.6 → 13. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + stage("2026-06-04T03:00:00.000Z", "CORE", 300.25), + stage("2026-06-04T05:00:00.000Z", "DEEP", 133.2499), + stage("2026-06-04T05:10:00.000Z", "AWAKE", 12.6), + ] as never); + const res = await GET(req("date=2026-06-04")); + const body = await res.json(); + expect(body.data.main.asleepMinutes).toBe(433); + expect(body.data.main.awakeMinutes).toBe(13); + expect(Number.isInteger(body.data.main.asleepMinutes)).toBe(true); + expect(Number.isInteger(body.data.main.awakeMinutes)).toBe(true); + }); + it("defaults to the most recent scorable night when date omitted", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 0bcdee635..8b308436e 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -56,9 +56,13 @@ function serializeSession(s: SleepSession) { source: s.source, start: s.start.toISOString(), end: s.end.toISOString(), - asleepMinutes: s.asleepMinutes, - inBedMinutes: s.inBedMinutes, - awakeMinutes: s.awakeMinutes, + // iOS #18 — round to whole minutes; the underlying totals are summed from + // second-precision segments and otherwise serialise as e.g. 433.4999. + // `inBedMinutes` / `awakeMinutes` stay null when the night never saw that + // stage — rounding only the present values. + asleepMinutes: Math.round(s.asleepMinutes), + inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes), + awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes), awakenings: s.awakenings, stages: s.stages, segments: s.segments.map((seg) => ({