diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 3d5997189..cbc3dc4a1 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -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)); diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index c464868a3..ef12bce2d 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -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 = []; + 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 }); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 6a00dd7ff..37c8fec6c 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -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;