From 8c745fca8d529d18724474077a745efd32cd6198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 18:30:06 +0200 Subject: [PATCH] fix(sleep): treat a short night as the night, only a daytime nap leaves the clock Keeping a nap-only day off the sleep rhythm used three hours asleep as the line, which also took every shorter night off the clock: someone who sleeps two and a half hours a night (insomnia, a newborn, split sleep) lost consistency, timing, chronotype and the health score's regularity altogether. A session now leaves the clock only when it is short AND centred in the daytime, outside 20:00 to 10:00 local time. A short night keeps its place, a long daytime sleep after a night shift stays a main sleep, and the withheld-timing note now reads that the latest day had only a nap. Refs #1204 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../__tests__/sleep-score-oura-rhythm.test.ts | 49 +++++++++++++++ src/lib/insights/derived/sleep-score.ts | 63 ++++++++++++++----- 9 files changed, 102 insertions(+), 24 deletions(-) diff --git a/messages/de.json b/messages/de.json index 944bc9638..dbbebfa63 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "noch nicht genug Nächte ({count} von 3)", - "noMainSleep": "letzte Nacht ohne Hauptschlaf" + "noMainSleep": "letzter Tag nur mit Nickerchen" } }, "READINESS": { diff --git a/messages/en.json b/messages/en.json index 261eed209..b2f276002 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "not enough nights yet ({count} of 3)", - "noMainSleep": "latest night had no main sleep" + "noMainSleep": "latest day had only a nap" } }, "READINESS": { diff --git a/messages/es.json b/messages/es.json index 5b42d2099..54a3ebd85 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "aún no hay suficientes noches ({count} de 3)", - "noMainSleep": "la última noche no tuvo sueño principal" + "noMainSleep": "el último día solo tuvo una siesta" } }, "READINESS": { diff --git a/messages/fr.json b/messages/fr.json index 46c71f58b..4bf94e642 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "pas encore assez de nuits ({count} sur 3)", - "noMainSleep": "pas de sommeil principal la dernière nuit" + "noMainSleep": "le dernier jour n'a eu qu'une sieste" } }, "READINESS": { diff --git a/messages/it.json b/messages/it.json index dd8413a22..5656b9413 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "notti ancora insufficienti ({count} su 3)", - "noMainSleep": "nessun sonno principale l'ultima notte" + "noMainSleep": "l'ultimo giorno solo un pisolino" } }, "READINESS": { diff --git a/messages/ko.json b/messages/ko.json index e3f8d67a8..ae222e3f6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "아직 밤이 부족함 ({count}/3)", - "noMainSleep": "최근 밤에 주 수면 없음" + "noMainSleep": "최근 하루는 낮잠만 있음" } }, "READINESS": { diff --git a/messages/pl.json b/messages/pl.json index 36cead91e..a8c699fd4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "za mało nocy ({count} z 3)", - "noMainSleep": "ostatniej nocy brak głównego snu" + "noMainSleep": "ostatni dzień tylko z drzemką" } }, "READINESS": { 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 index 8137d42ae..76dc6704c 100644 --- a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts @@ -24,6 +24,7 @@ import { mapSleep, type OuraSleep } from "@/lib/oura/client"; import { computeSleepScore, reconstructNights, + isDaytimeNap, rhythmGap, scoreConsistency, } from "../sleep-score"; @@ -137,6 +138,54 @@ describe("Sleep Score rhythm on an Oura month", () => { expect(regular.midpoint).not.toBeNull(); }); + it("keeps a short night on the clock: 2.5 hours asleep is still the night", async () => { + // A month of short nights, 01:30 to 04:00 Berlin (23:30 to 02:00 UTC), + // the shape of a newborn's first weeks or of insomnia. + const rows = Array.from({ length: 20 }, (_, i) => { + const d = i + 10; + const bed = new Date(Date.UTC(2026, 8, d, 23, 30 + ((d * 7) % 20))); + return period(`short-${d}`, "sleep", bed, 150); + }).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, + })), + ); + const nights = reconstructNights(rows as never, TZ); + expect(nights.length).toBe(20); + for (const n of nights) { + expect(n.asleepMinutes).toBeLessThan(180); + expect(n.midpoint).not.toBeNull(); + } + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.value.rhythmNights).toBe(20); + const sub = (k: string) => result.value.subScores.find((s) => s.key === k)!; + expect(sub("consistency").value).toBeGreaterThanOrEqual(70); + expect(sub("consistency").gap).toBeUndefined(); + expect(sub("timing").value).not.toBeNull(); + expect(sub("timing").gap).toBeUndefined(); + }); + + it("tells a nap from a night by length and time of day together", () => { + // Short and in the afternoon: a nap. + expect(isDaytimeNap(60, 14 * 60)).toBe(true); + // Short but at night: the night. + expect(isDaytimeNap(150, 2 * 60)).toBe(false); + expect(isDaytimeNap(150, 22 * 60)).toBe(false); + // A late sleeper's short night, centred at 09:00. + expect(isDaytimeNap(150, 9 * 60)).toBe(false); + // Long in the daytime (after a night shift): a main sleep. + expect(isDaytimeNap(420, 13 * 60)).toBe(false); + }); + it("scores the consistency of a steady sleeper, not 0", async () => { const result = await computeSleepScore("u1", PROFILE, { now: NOW }); expect(result.status).toBe("ok"); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index b0ebd4f39..c9a7a6bf1 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -441,20 +441,26 @@ export function reconstructNights( // 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. + // And only when that main session is a main sleep, not a daytime nap. 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. A short session at night is still + // the night (insomnia, a newborn, split sleep), so it keeps its place. const main = n.mainSession; + const mainMidpoint = main + ? minutesOfDay( + new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), + tz, + ) + : null; const midpoint = - main && main.asleepMinutes >= MAIN_SLEEP_MIN_MINUTES - ? minutesOfDay( - new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), - tz, - ) + main && + mainMidpoint != null && + !isDaytimeNap(main.asleepMinutes, mainMidpoint) + ? mainMidpoint : null; return { night: n.night, @@ -470,14 +476,37 @@ 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. + * Below this much asleep time a session can be a nap. 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). */ const MAIN_SLEEP_MIN_MINUTES = 180; +/** + * The night window, in local minutes-of-day: a session whose midpoint falls + * from 20:00 to 10:00 is night sleep however short it is. Wide enough for a + * late chronotype (asleep 04:00 to 12:00 has its midpoint at 08:00) and for + * an early one, and clear of the afternoon where naps sit. + */ +const NIGHT_WINDOW_START = 20 * 60; +const NIGHT_WINDOW_END = 10 * 60; + +/** + * True for a session that is a nap rather than a night: short (under + * `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night + * is still the night; a long daytime sleep (after a night shift) is still a + * main sleep. Pure. + */ +export function isDaytimeNap( + asleepMinutes: number, + midpointMinutes: number, +): boolean { + if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false; + const atNight = + midpointMinutes >= NIGHT_WINDOW_START || midpointMinutes < NIGHT_WINDOW_END; + return !atNight; +} + function minutesOfDay(d: Date, tz: string): number { if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes(); const { hour, minute } = wallClockInTz(d, tz);