fix(sleep-score): place each night's midpoint on its main session

A nap on the same wake day moved the night's latest instant to the end
of the nap and added its minutes, so the midpoint of a regular night
landed around noon. A few such days scored consistency at 0, and a nap
after the latest night scored timing at 0 as well. The night now carries
its main session (wake instant and asleep minutes), and the midpoint is
read from that alone. Sleep rhythm and chronotype read the same midpoint.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:10:18 +02:00
1 parent 228106be5f
commit d7588fe9bf
3 files changed
+73 -5

No files matched your search

+16
View File
@@ -316,6 +316,15 @@ export interface SleepNight {
* "sources disagree" hint.
*/
sourceDiscrepancy: SleepSourceDiscrepancy | null;
/**
* The night's MAIN session alone: the same-wake-day session with the most
* asleep minutes (the one `sourceDiscrepancy` reads), its wake instant and
* its own asleep minutes. A nap on the same wake day is in the night's
* totals but not here, so a reader that places the night on the clock (the
* sleep midpoint) is not dragged into the afternoon by it. Null when no
* session carries asleep minutes.
*/
mainSession: { end: Date; asleepMinutes: number } | null;
}
/** Sentinel for rows that carry no source — collapses to one bucket. */
@@ -738,6 +747,13 @@ export function reconstructSleepNights(
sourceDiscrepancy: mainSessionRows
? sessionSourceDiscrepancy(mainSessionRows)
: null,
mainSession:
mainSessionRows && mainSessionAsleep > 0
? {
end: new Date(mainSessionEnd),
asleepMinutes: mainSessionAsleep,
}
: null,
});
}
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
@@ -186,6 +186,21 @@ describe("reconstructNights", () => {
expect(nights[0].midpoint).toBe(180);
});
it("places the midpoint on the main session, not on a nap the same wake day", () => {
// Overnight 23:00 to 07:00 (480 asleep minutes, midpoint 03:00) and an
// afternoon nap 14:00 to 15:00 that wakes on the same day. The night's
// totals carry the nap; its place on the clock must not.
const rows = night("2026-06-02", [
["CORE", 60, "00:00"],
["CORE", 420, "07:00"],
["CORE", 60, "15:00"],
]);
const nights = reconstructNights(rows);
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(540);
expect(nights[0].midpoint).toBe(3 * 60);
});
it("expresses the midpoint in the user's timezone when one is passed", () => {
// Same 03:00Z midpoint, read in Asia/Kolkata (UTC+5:30, no DST) →
// 08:30 local = 510 min-of-day, not 180.
@@ -282,6 +297,39 @@ describe("reconstructNights", () => {
});
describe("computeSleepScore", () => {
it("keeps consistency and timing on a steady sleeper who naps some afternoons", async () => {
// Seven nights 23:00 to 07:00, every other wake day with a 14:00 to 15:00
// nap. The overnight rhythm never moves, so consistency and timing are
// perfect; reading the midpoint off the night's latest instant put the nap
// days about seven hours late and scored both at 0.
const rows: ReturnType<typeof night> = [];
for (let i = 0; i < 7; i += 1) {
const wake = new Date(Date.UTC(2026, 4, 27 + i));
const day = wake.toISOString().slice(0, 10);
rows.push(
...night(day, [
["IN_BED", 480, "07:00"],
["CORE", 240, "03:00"],
["DEEP", 120, "05:00"],
["REM", 120, "07:00"],
]),
);
if (i % 2 === 0) rows.push(...night(day, [["CORE", 60, "15:00"]]));
}
findMany.mockResolvedValue(rows);
const result = await computeSleepScore("u1", PROFILE, {
now: new Date("2026-06-02T16:00:00Z"),
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
const byKey = Object.fromEntries(
result.value.subScores.map((sub) => [sub.key, sub.value]),
);
expect(byKey.consistency).toBe(100);
expect(byKey.timing).toBe(100);
}
});
it("returns insufficient when no sleep rows exist", async () => {
findMany.mockResolvedValue([]);
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
+9 -5
View File
@@ -392,13 +392,17 @@ export function reconstructNights(
: n.asleepMinutes > 0 && awakeMinutes > 0
? n.asleepMinutes + awakeMinutes
: null;
// Midpoint = centre of the asleep span. The canonical night's `measuredAt`
// is the wake instant (latest stage END); the asleep span is
// `asleepMinutes` long ending there, so its centre is wake − asleep/2.
// Midpoint = centre of the MAIN session's asleep span: its wake instant
// minus half its own asleep minutes. Not the whole night's: a nap on the
// same wake day moves the night's latest instant to the end of the nap and
// 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.
const main = n.mainSession;
const midpoint =
n.asleepMinutes > 0
main && main.asleepMinutes > 0
? minutesOfDay(
new Date(n.measuredAt.getTime() - (n.asleepMinutes / 2) * 60_000),
new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000),
tz,
)
: null;