diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 4f4bce2c3..d5ba7fe52 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -4,6 +4,8 @@ import { reconstructSleepNights, reconstructSleepSessions, pickMainNightAndNaps, + isDaytimeNap, + separateNightsAndNaps, summarizeSleepNights, type SleepStageRow, } from "@/lib/analytics/sleep-night"; @@ -594,6 +596,65 @@ describe("pickMainNightAndNaps", () => { }); }); +describe("the one nap rule", () => { + 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("gives a day of naps alone no main night", () => { + // 14:00 to 15:00 Berlin, nothing overnight (the watch was charging). + const rows: SleepStageRow[] = [row("2026-06-04T13:00:00.000Z", "CORE", 60)]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + const { main, naps } = pickMainNightAndNaps(sessions); + expect(main).toBeNull(); + expect(naps).toHaveLength(1); + expect(naps[0].daytimeNap).toBe(true); + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights[0].asleepMinutes).toBe(60); + expect(nights[0].mainSession).toBeNull(); + expect(separateNightsAndNaps(sessions)).toEqual([ + { night: "2026-06-04", main: null, naps }, + ]); + }); + + it("keeps a short night as the main sleep over a longer afternoon nap", () => { + const rows: SleepStageRow[] = [ + // 02:00 to 03:30 Berlin: a broken night, 90 minutes. + row("2026-06-04T01:30:00.000Z", "CORE", 90), + // 13:00 to 15:00 Berlin: a two-hour nap. + row("2026-06-04T13:00:00.000Z", "CORE", 120), + ]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + const { main, naps } = pickMainNightAndNaps(sessions); + expect(main?.asleepMinutes).toBe(90); + expect(naps.map((n) => n.asleepMinutes)).toEqual([120]); + // The merged night places the same session on the clock. + const [night] = reconstructSleepNights(rows, "Europe/Berlin"); + expect(night.mainSession?.asleepMinutes).toBe(90); + expect(night.mainSession?.end.getTime()).toBe(main?.end.getTime()); + }); + + it("files a session under the day its last row ends on, over every writer", () => { + // The watch stops at 23:50 UTC; the phone's in-bed span runs to 00:10. + const rows: SleepStageRow[] = [ + srcRow("2026-06-03T23:50:00.000Z", "CORE", 170, "WHOOP"), + srcRow("2026-06-04T00:10:00.000Z", "IN_BED", 200, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + const nights = reconstructSleepNights(rows, "UTC"); + expect(sessions.map((s) => s.night)).toEqual(["2026-06-04"]); + expect(nights.map((n) => n.night)).toEqual(["2026-06-04"]); + }); +}); + describe("summarizeSleepNights", () => { it("summarises per-night totals, not per-stage rows", () => { const rows: SleepStageRow[] = [ diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts index 939558c0d..8e2363b1d 100644 --- a/src/lib/analytics/score/__tests__/pillars.test.ts +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -373,6 +373,38 @@ describe("reference-score pillars", () => { } }); + it("holds regularity back until 21 nights carry a midpoint, not 21 nights", () => { + const nights = days(24, 480).map((point, index) => ({ + night: point.day, + asleepMinutes: point.value, + // Four nap-only days count toward the level but have no place on the + // clock, which leaves 20 midpoints. + midpoint: index % 6 === 0 ? null : 180, + })); + const result = computeSleepPillar({ + ...live, + asOf: NOW, + ageYears: 40, + nights, + sources: ["APPLE_HEALTH"], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.observed.label).not.toContain("regularity"); + } + const enough = computeSleepPillar({ + ...live, + asOf: NOW, + ageYears: 40, + nights: nights.map((n) => ({ ...n, midpoint: 180 })), + sources: ["APPLE_HEALTH"], + }); + if (enough.status === "ok") { + expect(enough.value.observed.label).toContain("regularity"); + } + expect(enough.status).toBe("ok"); + }); + it("scores exactly 28 sleep calendar keys", () => { const nights = days(29, 600).map((point) => ({ night: point.day, diff --git a/src/lib/analytics/score/sleep.ts b/src/lib/analytics/score/sleep.ts index 086fd887c..01364708e 100644 --- a/src/lib/analytics/score/sleep.ts +++ b/src/lib/analytics/score/sleep.ts @@ -62,8 +62,11 @@ export function computeSleepPillar( const midpoints = nights .map((night) => night.midpoint) .filter((midpoint): midpoint is number => midpoint != null); + // The floor is on the nights that have a place on the clock: a day whose + // only sleep was a nap counts toward the level but carries no midpoint, so + // 21 nights could hold far fewer midpoints than the floor asks for. const regularity = - nights.length >= SLEEP_REGULARITY_MIN_NIGHTS + midpoints.length >= SLEEP_REGULARITY_MIN_NIGHTS ? scoreConsistency(midpoints) : null; // These are the existing Sleep Score's own declared sub-score weights, diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index ad1587147..6cb81ddcb 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -48,7 +48,7 @@ * measured on the absolute UTC instant, so it is immune to DST shifts. * * Each session is keyed by the LOCAL WAKE DAY — `userDayKey` of the session's - * LAST segment (its end instant) in the user's timezone. This matches the + * LAST row end over every writer, in the user's timezone. This matches the * Apple Health convention (a sleep session is attributed to the date you wake * up) and keeps the keying tz/DST-correct because it runs on the real instant. * @@ -58,9 +58,12 @@ * day under one key so the headline "time asleep" for a day is the day's * total. `reconstructSleepSessions` keeps each session SEPARATE, and * `pickMainNightAndNaps` applies the convention the UI needs: the MAIN night - * is the session with the most asleep minutes (normally the overnight block), - * and every other session on the same wake day is a NAP, surfaced separately - * and never folded into the main night's headline. + * is the main sleep with the most asleep minutes (normally the overnight + * block), and every other session on the same wake day is a NAP, surfaced + * separately and never folded into the main night's headline. A session is + * a nap rather than a main sleep when it is short and centred in the daytime + * (`isDaytimeNap`), so a day with an afternoon nap and no recorded night has + * no main night at all, rather than the nap standing in for one. * * Writer de-dup * ------------- @@ -127,6 +130,7 @@ */ import type { MeasurementSource, SleepStage } from "@/generated/prisma/client"; import { userDayKey } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { summarize, type DataSummary } from "@/lib/analytics/trends"; import { getDeviceTypeLadder, @@ -151,6 +155,17 @@ const GRANULAR_ASLEEP_STAGES: ReadonlySet = new Set([ "DEEP", ]); +/** + * Every stage that counts as asleep: the granular partition plus the bare + * `ASLEEP` aggregate. A stage-less row counts as asleep too, but it has no + * stage to list. The sleep list's SQL session cut splices this list, so the + * two decide "does this session hold sleep?" from one definition. + */ +export const ASLEEP_STAGES: readonly SleepStage[] = [ + "ASLEEP", + ...GRANULAR_ASLEEP_STAGES, +]; + /** * True when any row in the set carries a GRANULAR asleep stage * (CORE / DEEP / REM). When this holds, the bare `ASLEEP` aggregate (and any @@ -258,6 +273,70 @@ function inBedEnvelopeMinutes(rows: readonly SleepStageRow[]): number | null { */ export const SESSION_GAP_MS = 3 * 60 * 60 * 1000; +/** + * 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). + */ +export 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. + */ +export const NIGHT_WINDOW_START_MINUTES = 20 * 60; +export const NIGHT_WINDOW_END_MINUTES = 10 * 60; + +/** + * True for a session that is a nap rather than a main sleep: short (under + * `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night + * is still the night (insomnia, a newborn, split sleep); a long daytime sleep + * (after a night shift) is still a main sleep. The one nap rule: the nap + * convention, the sleep score's midpoint and every list read it. Pure. + */ +export function isDaytimeNap( + asleepMinutes: number, + midpointMinutes: number, +): boolean { + if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false; + const atNight = + midpointMinutes >= NIGHT_WINDOW_START_MINUTES || + midpointMinutes < NIGHT_WINDOW_END_MINUTES; + return !atNight; +} + +/** + * The centre of a session's asleep span as local minutes-of-day: its wake + * instant minus half its asleep minutes, read on the sleeper's wall clock. + */ +export function asleepMidpointMinutes( + end: Date, + asleepMinutes: number, + tz: string, +): number { + const mid = new Date(end.getTime() - (asleepMinutes / 2) * 60_000); + if (tz === "UTC") return mid.getUTCHours() * 60 + mid.getUTCMinutes(); + const { hour, minute } = wallClockInTz(mid, tz); + return hour * 60 + minute; +} + +/** + * The wake day a session is filed under: the local day its last row ends on, + * over every writer in the session. One rule for every reader, the SQL page + * cut of the sleep list included, which sees every writer and cannot know the + * winning one. Filing by the winner alone named a different day whenever a + * losing writer's in-bed span ran past midnight after the winner stopped, and + * the list and the night view then disagreed about which day the night was. + */ +function wakeDayOf(rows: readonly SleepStageRow[], tz: string): string { + let end = rows[0].measuredAt; + for (const r of rows) + if (r.measuredAt.getTime() > end.getTime()) end = r.measuredAt; + return userDayKey(end, tz); +} + export interface SleepStageRow { value: number; measuredAt: Date; @@ -309,9 +388,9 @@ export interface SleepNight { stages: Partial>; /** * Non-null when two writer buckets reported clearly different asleep - * totals for the night's MAIN session — the same-wake-day session with + * totals for the night's MAIN session — the same-wake-day main sleep with * the most asleep minutes, normally the overnight block (see - * `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's + * `pickMainNightAndNaps` and `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's * writers are not measured against the overnight's. Observational only — * the served `asleepMinutes` stays the winning writer's total; the * annotation lets the UI mark the number with a discreet @@ -319,12 +398,13 @@ export interface SleepNight { */ 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. + * The night's MAIN session alone, chosen by the nap convention + * (`pickMainNightAndNaps`: the main sleep 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, and on a day whose only sleep is daytime naps. */ mainSession: { end: Date; asleepMinutes: number } | null; } @@ -643,13 +723,9 @@ export function reconstructSleepNights( const canonical = pickSessionWriter(session, sleepLadder, deviceLadder); const kept = session.filter((r) => writerKeyOf(r) === canonical); const pool = kept.length > 0 ? kept : session; - // The wake day is the LATEST segment END in the session (Apple Health - // attributes a session to the morning you wake up). - const wakeInstant = pool.reduce( - (max, r) => (r.measuredAt.getTime() > max.getTime() ? r.measuredAt : max), - pool[0].measuredAt, - ); - const key = userDayKey(wakeInstant, tz); + // The wake day is the session's latest row end (Apple Health attributes + // a session to the morning you wake up), over every writer. + const key = wakeDayOf(session, tz); const list = byNight.get(key) ?? []; list.push({ pool, all: session }); byNight.set(key, list); @@ -671,9 +747,9 @@ export function reconstructSleepNights( // edits. let latest = nightSessions[0].pool[0].measuredAt; const stages: Partial> = {}; - // MAIN session of the night = most asleep minutes (same per-session - // asleep total the headline sums; tie → later wake instant, matching - // `pickMainNightAndNaps`). Its FULL raw row set (every writer, + // MAIN session of the night = the main sleep (not a daytime nap) with + // the most asleep minutes (same per-session asleep total the headline + // sums; tie → later wake instant), exactly as `pickMainNightAndNaps`. Its FULL raw row set (every writer, // pre-collapse) feeds the night's source-discrepancy annotation — // per session, so a nap's writers never cross-compare against the // overnight block's. @@ -731,6 +807,10 @@ export function reconstructSleepNights( } if ( sessionAsleep > 0 && + !isDaytimeNap( + sessionAsleep, + asleepMidpointMinutes(new Date(sessionEnd), sessionAsleep, tz), + ) && (sessionAsleep > mainSessionAsleep || (sessionAsleep === mainSessionAsleep && sessionEnd > mainSessionEnd)) ) { @@ -844,6 +924,12 @@ export interface SleepSession { * "sources disagree" hint. */ sourceDiscrepancy: SleepSourceDiscrepancy | null; + /** + * `true` when the session is a daytime nap rather than a main sleep + * (`isDaytimeNap`). Decided here, once, so the nap convention, the sleep + * score and every list read the same answer. + */ + daytimeNap: boolean; } /** Resolve a stage row to its absolute span (start = end − duration). */ @@ -1079,7 +1165,7 @@ export function reconstructSleepSessions( const asleep = asleepMinutesOf(pool); const canonicalSource = sourceOfWriterKey(canonical); sessions.push({ - night: userDayKey(latest, tz), + night: wakeDayOf(session, tz), source: canonicalSource === NO_SOURCE ? null @@ -1101,21 +1187,28 @@ export function reconstructSleepSessions( // losing bucket's disagreement is visible; the served totals above // stay winner-only. sourceDiscrepancy: sessionSourceDiscrepancy(session), + daytimeNap: isDaytimeNap( + asleep, + asleepMidpointMinutes(latest, asleep, tz), + ), }); } return sessions.sort((a, b) => a.start.getTime() - b.start.getTime()); } /** - * Apply the NAP convention to a wake-day's sessions: the MAIN night is the - * session with the most asleep minutes (normally the overnight block); every - * other session on the same wake day is a nap, surfaced separately and never - * folded into the main night's headline. + * Apply the NAP convention to a wake-day's sessions. The MAIN night is the + * main-sleep session (see `isDaytimeNap`) with the most asleep minutes, + * normally the overnight block; every other scorable session on the same wake + * day is a nap, surfaced separately and never folded into the main night's + * headline. A day whose only sleep is daytime naps has no main night: `main` + * is null and the naps are all there is. * * Returns the main session plus the naps (sorted by start). When the input * holds sessions across multiple wake days, only the sessions matching the - * main session's wake day are considered naps — call once per wake day, or - * pass a single wake day's sessions. + * main session's wake day (or, without one, the latest session's) are + * considered naps — call once per wake day, or pass a single wake day's + * sessions. */ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { main: SleepSession | null; @@ -1123,14 +1216,22 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { } { const scorable = sessions.filter((s) => s.asleepMinutes > 0); if (scorable.length === 0) return { main: null, naps: [] }; - // Main = most asleep minutes; tie-break on the later end (overnight wins). - const main = [...scorable].sort((a, b) => { - if (b.asleepMinutes !== a.asleepMinutes) - return b.asleepMinutes - a.asleepMinutes; - return b.end.getTime() - a.end.getTime(); - })[0]; + // Main = most asleep minutes among the main sleeps; tie-break on the later + // end (overnight wins). + const main = + scorable + .filter((s) => !s.daytimeNap) + .sort((a, b) => { + if (b.asleepMinutes !== a.asleepMinutes) + return b.asleepMinutes - a.asleepMinutes; + return b.end.getTime() - a.end.getTime(); + })[0] ?? null; + const day = + main?.night ?? + scorable.reduce((a, b) => (b.end.getTime() > a.end.getTime() ? b : a)) + .night; const naps = scorable - .filter((s) => s !== main && s.night === main.night) + .filter((s) => s !== main && s.night === day) .sort((a, b) => a.start.getTime() - b.start.getTime()); return { main, naps }; } @@ -1139,8 +1240,11 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { export interface SleepDaySplit { /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ night: string; - /** The overnight block — the session with the most asleep minutes. */ - main: SleepSession; + /** + * The main sleep (see `pickMainNightAndNaps`), or null on a day whose only + * sleep is daytime naps. + */ + main: SleepSession | null; /** Every other scorable session on the same wake day, sorted by start. */ naps: SleepSession[]; } @@ -1154,8 +1258,8 @@ export interface SleepDaySplit { * than growing its own idea of a night. There is exactly one nap classifier in * this codebase and it is `pickMainNightAndNaps`; this is its multi-day form. * - * Days whose sessions carry no asleep minutes are dropped (no main night to - * report). The result is sorted ascending by wake day. + * Days whose sessions carry no asleep minutes are dropped; a day of naps + * alone is kept with `main` null. The result is sorted ascending by wake day. */ export function separateNightsAndNaps( sessions: readonly SleepSession[], @@ -1169,7 +1273,7 @@ export function separateNightsAndNaps( const days: SleepDaySplit[] = []; for (const [night, daySessions] of byDay) { const { main, naps } = pickMainNightAndNaps(daySessions); - if (!main) continue; + if (!main && naps.length === 0) continue; days.push({ night, main, naps }); } return days.sort((a, b) => a.night.localeCompare(b.night)); diff --git a/src/lib/analytics/sleep-stage-composition.ts b/src/lib/analytics/sleep-stage-composition.ts index bf9489172..9424dbcd1 100644 --- a/src/lib/analytics/sleep-stage-composition.ts +++ b/src/lib/analytics/sleep-stage-composition.ts @@ -73,6 +73,8 @@ export function buildSleepStageComposition( const perNight: SleepStageCompositionNight[] = []; for (const { night, main, naps } of days) { + // A day of naps alone has no night to compose. + if (!main) continue; const nightStages: Record = {}; for (const [stage, mins] of Object.entries(main.stages)) { if (mins == null) continue; diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index be4e5291e..cfbc2e8eb 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -113,10 +113,12 @@ function seedSymptomDrivesSleep() { functionalImpact: burden, symptomLinks: [], }); + // A night of five to eight hours, waking at 07:00 UTC: a main sleep, not + // a midday nap, so the nap rule leaves it the night. measurements.push({ type: "SLEEP_DURATION", - value: 8 - burden, - measuredAt: new Date(BASE + (i + 1) * 86_400_000), + value: (8 - burden) * 60, + measuredAt: new Date(BASE + (i + 1) * 86_400_000 - 5 * 3_600_000), }); } measurementFindMany.mockResolvedValue(measurements); 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 76dc6704c..302bd93d3 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,7 +24,6 @@ import { mapSleep, type OuraSleep } from "@/lib/oura/client"; import { computeSleepScore, reconstructNights, - isDaytimeNap, rhythmGap, scoreConsistency, } from "../sleep-score"; @@ -174,18 +173,6 @@ describe("Sleep Score rhythm on an Oura month", () => { 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 c9a7a6bf1..aed680970 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -38,11 +38,11 @@ import type { SleepStage, } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { shiftDateKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { + asleepMidpointMinutes, reconstructSleepNights, type SleepStageRow, } from "@/lib/analytics/sleep-night"; @@ -434,34 +434,16 @@ export function reconstructNights( : n.asleepMinutes > 0 && awakeMinutes > 0 ? n.asleepMinutes + awakeMinutes : null; - // 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. - // - // 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. + // Midpoint = centre of the MAIN session's asleep span. Not the whole + // night's: a nap on the same wake day would drag it into the afternoon. + // The main session is the nap convention's (`pickMainNightAndNaps`), so + // a day whose only sleep is a daytime nap has none: it still counts its + // minutes, it just has no place on the clock, exactly as the night view + // and the list show it without a main night. const main = n.mainSession; - const mainMidpoint = main - ? minutesOfDay( - new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), - tz, - ) + const midpoint = main + ? asleepMidpointMinutes(main.end, main.asleepMinutes, tz) : null; - const midpoint = - main && - mainMidpoint != null && - !isDaytimeNap(main.asleepMinutes, mainMidpoint) - ? mainMidpoint - : null; return { night: n.night, asleepMinutes: n.asleepMinutes, @@ -475,44 +457,6 @@ export function reconstructNights( }); } -/** - * 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); - return hour * 60 + minute; -} - // ── compute ───────────────────────────────────────────────────────────── export interface SleepScoreOpts {