mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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
This commit is contained in:
1 parent
d38ceb4beb
commit
8c745fca8d
9 files changed
+102
-24
No files matched your search
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -4392,7 +4392,7 @@
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "아직 밤이 부족함 ({count}/3)",
|
||||
"noMainSleep": "최근 밤에 주 수면 없음"
|
||||
"noMainSleep": "최근 하루는 낮잠만 있음"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user