diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index b5a168eea..f345c88f3 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -5,6 +5,8 @@ import type { } from "@/generated/prisma/client"; import { reconstructSleepNights, + reconstructSleepSessions, + pickMainNightAndNaps, summarizeSleepNights, type SleepStageRow, } from "@/lib/analytics/sleep-night"; @@ -243,6 +245,130 @@ describe("reconstructSleepNights", () => { }); }); +describe("reconstructSleepSessions", () => { + it("resolves each stage row to start = end − duration", () => { + const rows: SleepStageRow[] = [ + // 240-min CORE ending 03:00 UTC → starts 23:00 the previous instant. + row("2026-06-04T03:00:00.000Z", "CORE", 240), + row("2026-06-04T04:30:00.000Z", "REM", 90), // 03:00 → 04:30 + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + const s = sessions[0]; + expect(s.start.toISOString()).toBe("2026-06-03T23:00:00.000Z"); + expect(s.end.toISOString()).toBe("2026-06-04T04:30:00.000Z"); + // Segments carry their own start/end span. + const core = s.segments.find((seg) => seg.stage === "CORE"); + expect(core?.start.toISOString()).toBe("2026-06-03T23:00:00.000Z"); + expect(core?.end.toISOString()).toBe("2026-06-04T03:00:00.000Z"); + expect(s.asleepMinutes).toBe(330); + }); + + it("splits a daytime nap into its own session, separate from the night", () => { + const rows: SleepStageRow[] = [ + row("2026-06-03T13:00:00.000Z", "CORE", 45), // 14:15 → 15:00 nap + // Overnight contiguous block. + row("2026-06-03T21:00:00.000Z", "CORE", 200), + row("2026-06-03T23:00:00.000Z", "DEEP", 120), + row("2026-06-04T01:00:00.000Z", "REM", 120), + row("2026-06-04T04:00:00.000Z", "CORE", 180), + ]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + expect(sessions).toHaveLength(2); + // Sorted ascending by start: nap first, overnight second. + expect(sessions[0].asleepMinutes).toBe(45); + expect(sessions[1].asleepMinutes).toBe(620); + }); + + it("keeps only the canonical source's segments on a dual-source night", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), + srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"), + srcRow("2026-06-04T03:05:00.000Z", "DEEP", 85, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("WHOOP"); + // Only WHOOP's 3 segments survive; Apple Health's are dropped. + expect(sessions[0].segments).toHaveLength(3); + expect(sessions[0].asleepMinutes).toBe(420); + }); + + it("counts only MID-sleep AWAKE bouts as awakenings", () => { + const rows: SleepStageRow[] = [ + // Leading AWAKE before sleep onset — NOT an awakening. + row("2026-06-04T00:10:00.000Z", "AWAKE", 10), + row("2026-06-04T02:00:00.000Z", "CORE", 110), + // Mid-sleep AWAKE bout — counts. + row("2026-06-04T02:15:00.000Z", "AWAKE", 15), + row("2026-06-04T04:00:00.000Z", "DEEP", 105), + // Another mid-sleep AWAKE bout — counts. + row("2026-06-04T04:10:00.000Z", "AWAKE", 10), + row("2026-06-04T06:00:00.000Z", "REM", 110), + // Trailing AWAKE after final wake — NOT an awakening. + row("2026-06-04T06:05:00.000Z", "AWAKE", 5), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].awakenings).toBe(2); + }); + + it("returns no sessions for empty input", () => { + expect(reconstructSleepSessions([], "UTC")).toEqual([]); + }); +}); + +describe("pickMainNightAndNaps", () => { + it("picks the longest-asleep session as the main night, rest as naps", () => { + // Same wake day (Berlin): a short nap + the overnight block. + const rows: SleepStageRow[] = [ + row("2026-06-04T11:00:00.000Z", "CORE", 40), // 12:00 → 12:40 nap (Jun 4) + // Overnight ~22:00 Jun 3 → 06:00 Jun 4. + row("2026-06-03T21:00:00.000Z", "CORE", 180), + row("2026-06-04T00:00:00.000Z", "DEEP", 120), + row("2026-06-04T04:00:00.000Z", "REM", 180), + ]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + const { main, naps } = pickMainNightAndNaps(sessions); + expect(main?.asleepMinutes).toBe(480); + expect(naps).toHaveLength(1); + expect(naps[0].asleepMinutes).toBe(40); + // Nap shares the main night's wake day. + expect(naps[0].night).toBe(main?.night); + }); + + it("returns no main when every session is IN_BED/AWAKE only", () => { + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", "IN_BED", 60), + row("2026-06-04T06:00:00.000Z", "AWAKE", 60), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + const { main, naps } = pickMainNightAndNaps(sessions); + expect(main).toBeNull(); + expect(naps).toEqual([]); + }); + + it("does not surface a different wake day's session as a nap", () => { + // Two overnight blocks on consecutive wake days — neither is the + // other's nap even though both land in the same input array. + const rows: SleepStageRow[] = [ + // Wake day Jun 3. + row("2026-06-03T01:00:00.000Z", "CORE", 240), + row("2026-06-03T05:00:00.000Z", "DEEP", 120), + // Wake day Jun 4. + row("2026-06-04T01:00:00.000Z", "CORE", 300), + row("2026-06-04T05:00:00.000Z", "DEEP", 120), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + const { main, naps } = pickMainNightAndNaps(sessions); + // Jun 4 is the longer night. + expect(main?.night).toBe("2026-06-04"); + expect(naps).toEqual([]); + }); +}); + describe("summarizeSleepNights", () => { it("summarises per-night totals, not per-stage rows", () => { const rows: SleepStageRow[] = [ diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index a7bcd9c50..90810a61b 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -47,6 +47,16 @@ * 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. * + * Naps vs the main night + * ---------------------- + * `reconstructSleepNights` MERGES every session that lands on the same wake + * 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. + * * Source de-dup * ------------- * A user paired to more than one sleep source (e.g. WHOOP + Apple Health) @@ -320,6 +330,212 @@ export function reconstructSleepNights( return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); } +/** + * One reconstructed sleep SEGMENT — a single stage row resolved to its + * absolute start / end span. `start = measuredAt − value·60_000` because + * `measuredAt` is the segment END instant (`apple-health-mapping.ts` sets + * `takenAt = endDate`). Used by the hypnogram view to lay each stage onto + * a clock-time lane. + */ +export interface SleepSegment { + stage: SleepStage | null; + /** Absolute segment start instant (end − duration). */ + start: Date; + /** Absolute segment end instant (= the stored `measuredAt`). */ + end: Date; + /** Duration in minutes (= the stored `value`). */ + minutes: number; +} + +/** + * One reconstructed sleep SESSION — a contiguous block of segments (no + * gap > `SESSION_GAP_MS`) collapsed to ONE canonical source. This is the + * unit the hypnogram renders and the nap convention separates (main night + * vs nap). Distinct from `SleepNight`, which merges same-wake-day sessions + * under one key for the headline number. + */ +export interface SleepSession { + /** Wake-day key (YYYY-MM-DD) of the session's last segment, in user tz. */ + night: string; + /** The canonical ingest source whose segments were kept (null = none). */ + source: MeasurementSource | null; + /** Session start instant — earliest segment start. */ + start: Date; + /** Session end instant — latest segment end (the wake instant). */ + end: Date; + /** Time asleep in minutes (granular-over-bare rule). */ + asleepMinutes: number; + /** In-bed minutes when an IN_BED segment exists, else null. */ + inBedMinutes: number | null; + /** Awake-in-bed minutes when an AWAKE segment exists, else null. */ + awakeMinutes: number | null; + /** Per-stage minutes for the session (only stages the device reported). */ + stages: Partial>; + /** + * Count of AWAKE segments between the first and last asleep segment — + * the mid-sleep awakenings (a leading / trailing AWAKE before sleep + * onset or after final wake is not an awakening). Computed from the + * canonical source's segments only. + */ + awakenings: number; + /** The canonical source's segments, sorted by start, for the hypnogram. */ + segments: SleepSegment[]; +} + +/** Resolve a stage row to its absolute span (start = end − duration). */ +function segmentOf(r: SleepStageRow): SleepSegment { + const minutes = Number.isFinite(r.value) ? r.value : 0; + const end = r.measuredAt; + const start = new Date(end.getTime() - minutes * 60_000); + return { stage: r.sleepStage, start, end, minutes }; +} + +/** + * Count mid-sleep awakenings from a session's segments. An AWAKE segment + * counts only when it sits BETWEEN the first and last asleep segment — a + * leading AWAKE before sleep onset or a trailing AWAKE after the final + * wake is settling-in / lie-in, not a fragmentation event. IN_BED is + * ignored entirely. + */ +function countAwakenings(segments: readonly SleepSegment[]): number { + const sorted = [...segments].sort( + (a, b) => a.start.getTime() - b.start.getTime(), + ); + const isAsleep = (s: SleepSegment): boolean => + s.stage != null && + (GRANULAR_ASLEEP_STAGES.has(s.stage) || s.stage === "ASLEEP"); + let firstAsleep = -1; + let lastAsleep = -1; + for (let i = 0; i < sorted.length; i++) { + if (isAsleep(sorted[i])) { + if (firstAsleep === -1) firstAsleep = i; + lastAsleep = i; + } + } + if (firstAsleep === -1 || lastAsleep <= firstAsleep) return 0; + let count = 0; + for (let i = firstAsleep + 1; i < lastAsleep; i++) { + if (sorted[i].stage === "AWAKE") count += 1; + } + return count; +} + +/** + * Reconstruct per-SESSION sleep blocks from raw per-stage rows. Unlike + * `reconstructSleepNights` (which merges same-wake-day sessions under one + * key for the single headline number), this returns EACH session + * separately so a caller can render the night's hypnogram and surface a + * daytime nap as its own block. Every session is collapsed to ONE + * canonical source via the user's `sleep` priority ladder, so two sources' + * timelines never overlay. + * + * Pure — the caller does the bounded DB read. Sessions are returned sorted + * ascending by start, so the last element is the most recent session. + */ +export function reconstructSleepSessions( + rows: SleepStageRow[], + tz: string, + priorityJson: unknown = null, +): SleepSession[] { + if (rows.length === 0) return []; + const sleepLadder = getSourceLadder(parseSourcePriority(priorityJson), "sleep"); + + const startOf = (r: SleepStageRow): number => + r.measuredAt.getTime() - + (Number.isFinite(r.value) ? r.value : 0) * 60_000; + const sorted = [...rows].sort((a, b) => startOf(a) - startOf(b)); + const rawSessions: SleepStageRow[][] = []; + let current: SleepStageRow[] = []; + let sessionEnd = Number.NEGATIVE_INFINITY; + for (const r of sorted) { + const start = startOf(r); + const end = r.measuredAt.getTime(); + if (current.length > 0 && start - sessionEnd > SESSION_GAP_MS) { + rawSessions.push(current); + current = []; + sessionEnd = Number.NEGATIVE_INFINITY; + } + current.push(r); + if (end > sessionEnd) sessionEnd = end; + } + if (current.length > 0) rawSessions.push(current); + + const sessions: SleepSession[] = []; + for (const session of rawSessions) { + const canonical = pickSessionSource(session, sleepLadder); + const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical); + const pool = kept.length > 0 ? kept : session; + const segments = pool + .map(segmentOf) + .sort((a, b) => a.start.getTime() - b.start.getTime()); + + let inBed = 0; + let awake = 0; + let sawInBed = false; + let sawAwake = false; + const stages: Partial> = {}; + let earliest = segments[0].start; + let latest = segments[0].end; + for (const seg of segments) { + if (seg.start.getTime() < earliest.getTime()) earliest = seg.start; + if (seg.end.getTime() > latest.getTime()) latest = seg.end; + const stage = seg.stage; + if (stage) stages[stage] = (stages[stage] ?? 0) + seg.minutes; + if (stage === "IN_BED") { + inBed += seg.minutes; + sawInBed = true; + } else if (stage === "AWAKE") { + awake += seg.minutes; + sawAwake = true; + } + } + const asleep = asleepMinutesOf(pool); + sessions.push({ + night: userDayKey(latest, tz), + source: + canonical === NO_SOURCE ? null : (canonical as MeasurementSource), + start: earliest, + end: latest, + asleepMinutes: asleep, + inBedMinutes: sawInBed ? inBed : null, + awakeMinutes: sawAwake ? awake : null, + stages, + awakenings: countAwakenings(segments), + segments, + }); + } + 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. + * + * 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. + */ +export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { + main: SleepSession | null; + naps: 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]; + const naps = scorable + .filter((s) => s !== main && s.night === main.night) + .sort((a, b) => a.start.getTime() - b.start.getTime()); + return { main, naps }; +} + export interface SleepNightSummary { /** * A `DataSummary` whose every value is a per-night TIME-ASLEEP total