From 7f2f473a589b44d97b3c21ce886523d449d1061a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 10:25:30 +0200 Subject: [PATCH] fix(sleep): keep a nap-only day off the sleep rhythm A day whose ring or watch recorded no night has a nap as its largest session, and its afternoon midpoint sat hours from every other night. One such day in a month pushed the midpoint spread past the 90-minute full scale and read a regular rhythm as Consistency 0. A day's midpoint now needs a main session of at least three hours asleep, the line wearables draw between a nap and a main sleep. The day keeps its minutes; it only has no place on the clock for consistency, timing, chronotype and the health score's regularity. Refs #1204 --- .../__tests__/sleep-score-oura-rhythm.test.ts | 161 ++++++++++++++++++ src/lib/insights/derived/sleep-score.ts | 19 ++- 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts diff --git a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts new file mode 100644 index 000000000..4a2eb995a --- /dev/null +++ b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts @@ -0,0 +1,161 @@ +/** + * The Sleep Score's rhythm sub-scores (consistency, timing) for a ring wearer + * whose recent nights come from Oura while the phone's Apple Health history is + * still being read from years back. The Oura month carries the periods Oura + * really returns: a `long_sleep` most nights, short `sleep` naps, a `late_nap` + * in the evening, a `rest` period the person rejected, and two days on which + * the ring recorded no night at all and only a daytime nap. Rows go through + * the real Oura mapper, so the fixture has the shape the sync writes. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + user: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), +})); + +import { prisma } from "@/lib/db"; +import { mapSleep, type OuraSleep } from "@/lib/oura/client"; +import { + computeSleepScore, + reconstructNights, + scoreConsistency, +} from "../sleep-score"; + +const TZ = "Europe/Berlin"; +const NOW = new Date("2026-10-01T09:00:00Z"); +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const findMany = prisma.measurement.findMany as ReturnType; + +interface Row { + value: number; + measuredAt: Date; + sleepStage: string | null; + source: string; + deviceType: string | null; +} + +/** A 5-minute hypnogram: awake at both ends, light / deep / REM between. */ +function hypnogram(minutes: number): string { + const n = Math.round(minutes / 5); + const body = "2221133322224222113332"; + let s = "444"; + for (let i = 0; i < n - 5; i += 1) s += body[i % body.length]; + return s + "44"; +} + +function period( + id: string, + type: string, + start: Date, + minutes: number, +): OuraSleep { + const end = new Date(start.getTime() + minutes * 60_000); + return { + id, + type, + day: end.toISOString().slice(0, 10), + bedtime_start: start.toISOString(), + bedtime_end: end.toISOString(), + sleep_phase_5_min: hypnogram(minutes), + efficiency: 90, + }; +} + +/** Thirty Oura days ending the morning of NOW, plus Apple Health from 2024. */ +function fixture(): Row[] { + const periods: OuraSleep[] = []; + for (let d = 1; d <= 30; d += 1) { + // Bedtime 23:15 to 00:05 Berlin (21:15 to 22:05 UTC), eight hours. + const bed = new Date(Date.UTC(2026, 8, d, 21, 15 + ((d * 17) % 50))); + const noon = (h: number, m = 0) => new Date(Date.UTC(2026, 8, d + 1, h, m)); + if (d === 9 || d === 21) { + // The ring spent the night on the charger: the day has only a nap. + periods.push(period(`only-${d}`, "sleep", noon(11), 60)); + } else { + periods.push(period(`night-${d}`, "long_sleep", bed, 480)); + } + if (d % 5 === 0) periods.push(period(`nap-${d}`, "sleep", noon(10), 40)); + if (d % 7 === 0) + periods.push(period(`late-${d}`, "late_nap", noon(15, 30), 45)); + if (d % 6 === 0) periods.push(period(`rest-${d}`, "rest", noon(11), 90)); + } + const oura = periods.flatMap((p) => + mapSleep(p, TZ) + .filter((m) => m.type === "SLEEP_DURATION") + .map((m) => ({ + value: m.value, + measuredAt: m.measuredAt, + sleepStage: m.sleepStage ?? null, + source: "OURA", + deviceType: null, + })), + ); + // The Watch history the phone is still working through, two years back. + const apple: Row[] = []; + for (let d = 1; d <= 20; d += 1) { + apple.push({ + value: 420, + measuredAt: new Date(Date.UTC(2024, 8, d, 5, 0)), + sleepStage: "CORE", + source: "APPLE_HEALTH", + deviceType: "watch", + }); + } + return [...apple, ...oura]; +} + +beforeEach(() => { + vi.clearAllMocks(); + const rows = fixture(); + // Honour the window the way the database would. + findMany.mockImplementation( + async (args: { where: { measuredAt: { gte: Date } } }) => + rows + .filter((r) => r.measuredAt >= args.where.measuredAt.gte) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()), + ); +}); + +describe("Sleep Score rhythm on an Oura month", () => { + it("keeps a nap-only day off the clock", () => { + const nights = reconstructNights(fixture() as never, TZ).filter( + (n) => n.night >= "2026-09-02", + ); + const napOnly = nights.find((n) => n.night === "2026-09-10")!; + // The day still counts its nap... + expect(napOnly.asleepMinutes).toBeGreaterThan(0); + // ...but a nap is no point on the sleep rhythm. + expect(napOnly.midpoint).toBeNull(); + const regular = nights.find((n) => n.night === "2026-09-11")!; + expect(regular.midpoint).not.toBeNull(); + }); + + it("scores the consistency of a steady sleeper, not 0", async () => { + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + const sub = (k: string) => + result.value.subScores.find((s) => s.key === k)!.value; + // Bedtimes wander within 50 minutes: a regular rhythm. + expect(sub("consistency")).toBeGreaterThanOrEqual(70); + expect(sub("timing")).toBeGreaterThanOrEqual(60); + // The Apple Health rows from 2024 never enter the window. + for (const call of findMany.mock.calls) { + expect(call[0].where.measuredAt.gte.getTime()).toBeGreaterThan( + Date.UTC(2026, 7, 31), + ); + } + }); + + it("still reads a truly irregular rhythm as irregular", () => { + // Midpoints spread across the night: 01:00, 04:00, 07:00, repeated. + const spread = Array.from({ length: 12 }, (_, i) => 60 + (i % 3) * 180); + expect(scoreConsistency(spread)).toBe(0); + }); +}); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 37c8fec6c..fa24e526d 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -398,9 +398,17 @@ export function reconstructNights( // adds its minutes, which put the midpoint of a 23:00 to 07:00 night with a // 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in // consistency and timing. + // + // And only when that main session is a main sleep. On a day the ring or + // watch recorded no night (left on the charger, taken off), the day's + // largest session is an afternoon nap, and its 14:00 midpoint sat ten + // hours from every other night: a single such day in a month pushed the + // midpoint spread past the 90-minute full scale and read the whole + // rhythm as Consistency 0. Such a day still counts its minutes; it just + // has no place on the clock. const main = n.mainSession; const midpoint = - main && main.asleepMinutes > 0 + main && main.asleepMinutes >= MAIN_SLEEP_MIN_MINUTES ? minutesOfDay( new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), tz, @@ -419,6 +427,15 @@ export function reconstructNights( }); } +/** + * The least asleep time a day's main session needs before its midpoint is a + * point on the sleep rhythm (consistency, timing, chronotype, regularity). + * Three hours is where wearables draw the line between a nap and a main + * sleep (Oura types a period `long_sleep` from three hours on), and well + * above any nap, while a short night still clears it. + */ +const MAIN_SLEEP_MIN_MINUTES = 180; + function minutesOfDay(d: Date, tz: string): number { if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes(); const { hour, minute } = wallClockInTz(d, tz);