/** * v1.11.4 — per-night sleep aggregation (iOS #1, HIGH). * * `SLEEP_DURATION` is stored in MINUTES, one row per sleep STAGE per * night (IN_BED / AWAKE / ASLEEP / REM / CORE / DEEP) since v1.4.23. The * value is NOT cumulative — a single stage row carries only that stage's * minutes. Every "last night's sleep" surface therefore has to SUM the * stage rows belonging to one night before it shows a headline number; * reading the single most-recent row gives one stage, not the night. * * This helper is the one shared place that turns the raw per-stage rows * into per-night totals. The headline "time asleep" is light(CORE) + * deep + REM; IN_BED and AWAKE are excluded from the asleep total, * matching the AASM convention. * * Granular-over-bare (v1.11.5) * ---------------------------- * Apple Health writes BOTH an unspecified `ASLEEP` AGGREGATE row AND the * granular `CORE` / `DEEP` / `REM` breakdown for the SAME sleep period. The * granular rows partition the same time the aggregate covers, so counting * bare-ASLEEP together with the granular rows ~doubles the night. We therefore * prefer the granular partition: when ANY granular stage (CORE / DEEP / REM) * is present for a session we drop the redundant bare `ASLEEP` aggregate (and * any stage-less row) EVERYWHERE — the asleep total, the per-stage `stages` * breakdown, and the hypnogram `segments` list — so none of those views holds * the granular set plus its ~equal bare twin. The bare `ASLEEP` aggregate (or * a stage-less legacy / manual row) is the session's only asleep signal when * no granular stage exists. WHOOP never emits a bare `ASLEEP` aggregate (its * stage map writes CORE / DEEP / REM / AWAKE / IN_BED only — see * `whoop/client.ts`), so this gate is Apple-Health-only in practice. * * Night grouping * -------------- * A "night" is one SLEEP SESSION, not a calendar day. `measuredAt` is the * stage segment's END instant (`apple-health-mapping.ts` sets * `takenAt = endDate`), and a device writes one row per stage segment, so a * normal overnight sleep (asleep before local midnight) spreads its segments * across BOTH sides of midnight. Keying each segment by its own calendar day * would split one physical night into two partial nights and undercount the * "last night" headline. * * Instead we cluster the segments into sessions by time gap: sort by * `measuredAt`, and start a new session whenever the gap from the previous * segment exceeds `SESSION_GAP_MS` (3 h). Stage segments inside one sleep * session are contiguous (minutes to ~2 h apart), so a > 3 h gap reliably * marks the boundary between a daytime nap and the overnight session, or * between two genuine sessions in one day — they stay separable. The gap is * 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 * 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. * * Writer de-dup * ------------- * A user paired to more than one sleep source (e.g. WHOOP + Apple Health) * gets per-stage rows for the SAME night from each source. Summing them all * would ~double the night total. Before summing a session we collapse it to * ONE writer. A "writer" is `(source, deviceType)` — finer than `source` * because several HealthKit writer apps land behind the SAME * `source = APPLE_HEALTH` (the watch's granular stages, the phone's coarse * in-bed detection, a wearable vendor app's re-export). Keying the collapse * on source alone blends those writers into one bucket: the phone's * awake/in-bed blocks inflate the night's awake total and sit on top of the * watch's hypnogram. Rows without a `deviceType` collapse per source exactly * as before, so legacy rows and single-writer sources are unaffected. * * The pick is per-night by STAGE RICHNESS first: the writer carrying the * most distinct granular stages (CORE / DEEP / REM) wins, so a coarse * awake/asleep-only export can never mask a writer that knows the night's * phases. Among equally rich writers the user's `sleep` source-priority * ladder decides (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > * WITHINGS), then most asleep minutes, then a stable key tiebreak. Only the * winning writer's segments are summed — writers are never blended within a * session, mirroring the per-(type, day) collapse the rest of the v1.11.x * numeric surfaces apply. * * One exception: `inBedMinutes` ("Zeit im Bett") derives from the UNION * of the IN_BED spans across ALL writers in the session, not from the * winner alone. The common pairing is a watch that knows the stages but * writes no IN_BED row and a phone whose bedtime detection writes only * the long IN_BED window — the watch wins the night on richness, and a * winner-only in-bed figure would shrink to nothing exactly when the * stage data is best. Overlapping spans from two writers are merged * interval-union style so a doubled export never double-counts. The * asleep total, the `stages` map, and `awakeMinutes` stay winner-only — * blending the phone's coarse AWAKE blocks into the watch's night was * the original inflated-awake bug this collapse exists to prevent. * * Unit * ---- * Totals are returned in MINUTES — the canonical `SLEEP_DURATION` unit * (`getUnitForType` → "minutes") — so the existing web consumers that * already treat the summary as minutes (e.g. the insights overview's * `formatHoursMinutes`) keep working unchanged; they now average per- * night totals instead of per-stage rows, which is strictly more * correct. Callers that want hours convert at the edge (`/ 60`). */ import type { MeasurementSource, SleepStage } from "@/generated/prisma/client"; import { userDayKey } from "@/lib/tz/format"; import { summarize, type DataSummary } from "@/lib/analytics/trends"; import { getSourceLadder, parseSourcePriority, } from "@/lib/validations/source-priority"; /** * The GRANULAR asleep stages (iOS 16+ `asleepCore` / `asleepDeep` / * `asleepREM`). A bare `ASLEEP` row is the iOS 15- `asleepUnspecified` * AGGREGATE — Apple Health writes BOTH the unspecified aggregate AND the * granular breakdown for the SAME period, so keeping bare-ASLEEP alongside the * granular rows ~doubles the night. The granular rows partition the same time * the aggregate covers, so when any granular stage is present for a session we * keep ONLY the granular stages and drop the bare aggregate; the bare row is * the asleep signal only when no granular stage exists. WHOOP never writes a * bare `ASLEEP` aggregate (see `whoop/client.ts` `SLEEP_STAGE_MAP`). */ const GRANULAR_ASLEEP_STAGES: ReadonlySet = new Set([ "REM", "CORE", "DEEP", ]); /** * True when any row in the set carries a GRANULAR asleep stage * (CORE / DEEP / REM). When this holds, the bare `ASLEEP` aggregate (and any * stage-less row) is the redundant unspecified twin of the granular partition * and must be dropped from EVERY downstream view — the asleep total, the * per-stage `stages` map, and the hypnogram `segments` list — so none of them * double-counts the same period. */ function sawGranularStage(rows: readonly SleepStageRow[]): boolean { for (const r of rows) { const stage = r.sleepStage; if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) return true; } return false; } /** * A stage row is a redundant bare-asleep aggregate (must be dropped) when the * session/night ALSO carries a granular CORE/DEEP/REM partition. Only the bare * `ASLEEP` aggregate and stage-less (`null`) rows are redundant — IN_BED / * AWAKE and the granular stages themselves are always kept. */ function isRedundantBareAsleep( stage: SleepStage | null, sawGranular: boolean, ): boolean { return sawGranular && (stage === "ASLEEP" || stage == null); } /** * Sum a set of stage rows into time-asleep minutes WITHOUT double-counting a * bare `ASLEEP` aggregate against the granular CORE/DEEP/REM rows it overlaps. * IN_BED + AWAKE are never asleep; a stage-less (`null`) row is the night's * total only when no granular stage is present. */ function asleepMinutesOf(rows: readonly SleepStageRow[]): number { let granular = 0; let fallback = 0; let sawGranular = false; for (const r of rows) { const minutes = Number.isFinite(r.value) ? r.value : 0; const stage = r.sleepStage; if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) { granular += minutes; sawGranular = true; } else if (stage === "ASLEEP" || stage == null) { // Bare unspecified aggregate (`ASLEEP`) or a stage-less legacy/manual // row — only used when no granular stage carries the night. fallback += minutes; } // IN_BED / AWAKE never count toward asleep. } return sawGranular ? granular : fallback; } /** * Total minutes covered by the UNION of the IN_BED spans across EVERY * writer in a session ("Zeit im Bett"). Each IN_BED row resolves to the * absolute span `[measuredAt − value·60_000, measuredAt]`; overlapping * spans (the same window exported by two writers) merge before summing * so a doubled export never double-counts the night. Returns `null` * when no IN_BED row exists anywhere in the session — the caller keeps * the "no in-bed signal" semantics. Deliberately NOT winner-scoped: the * stage-rich watch usually writes no IN_BED row while the phone writes * only the envelope, and the headline must keep both facets. */ function inBedEnvelopeMinutes(rows: readonly SleepStageRow[]): number | null { const intervals: Array<{ start: number; end: number }> = []; for (const r of rows) { if (r.sleepStage !== "IN_BED") continue; const minutes = Number.isFinite(r.value) ? r.value : 0; const end = r.measuredAt.getTime(); intervals.push({ start: end - minutes * 60_000, end }); } if (intervals.length === 0) return null; intervals.sort((a, b) => a.start - b.start); let total = 0; let curStart = intervals[0].start; let curEnd = intervals[0].end; for (let i = 1; i < intervals.length; i++) { const { start, end } = intervals[i]; if (start <= curEnd) { if (end > curEnd) curEnd = end; } else { total += curEnd - curStart; curStart = start; curEnd = end; } } total += curEnd - curStart; return Math.round(total / 60_000); } /** * Gap (ms) between consecutive stage segments that starts a new sleep * session. Stage segments within one night are contiguous (minutes to a * couple of hours apart); a > 3 h gap separates a nap from the overnight * block, or two genuine sessions in one day. */ const SESSION_GAP_MS = 3 * 60 * 60 * 1000; export interface SleepStageRow { value: number; measuredAt: Date; sleepStage: SleepStage | null; /** * Ingest source of the stage row. Used to collapse a multi-source night * (e.g. WHOOP + Apple Health) to one canonical source before summing. * Optional so legacy callers and fixtures that don't carry a source still * type-check (a missing source is treated as a single anonymous source — * the de-dup is a no-op for single-source nights). */ source?: MeasurementSource | null; /** * Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25 * W8c). Distinguishes WRITERS behind one source: several HealthKit apps * write sleep under `source = APPLE_HEALTH` (watch granular stages, phone * coarse in-bed, vendor-app re-exports), and collapsing on source alone * blends them. Optional — callers that don't select the column (and every * row stored before W8c) collapse per source exactly as before. */ deviceType?: string | null; } /** One reconstructed night: the asleep total + the per-stage breakdown. */ export interface SleepNight { /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ night: string; /** Latest stage instant in the night — the night's representative timestamp. */ measuredAt: Date; /** Time asleep in minutes = CORE + DEEP + REM, or bare ASLEEP when granular stages are absent. */ asleepMinutes: number; /** * In-bed minutes when an IN_BED row exists ANYWHERE in the night's * sessions, else null. Union envelope across ALL writers — not * winner-scoped like `asleepMinutes` / `stages` / `awakeMinutes` — * so a stage-rich watch winning the night never erases the phone's * in-bed window. See the module doc's writer-de-dup exception. */ inBedMinutes: number | null; /** Awake-in-bed minutes when an AWAKE row exists, else null. */ awakeMinutes: number | null; /** Per-stage minutes for the night (only stages the winning writer reported). */ stages: Partial>; } /** Sentinel for rows that carry no source — collapses to one bucket. */ const NO_SOURCE = "__none__"; /** * Separator between the source and device-type parts of a writer key. * `` cannot appear in a `MeasurementSource` enum literal or a * device-type tag, so the split-back is unambiguous. */ const WRITER_KEY_SEP = ""; /** * Writer-bucket key of a stage row: the source, refined by the device-type * tag when one is stored. Two HealthKit writer apps behind the same * `APPLE_HEALTH` source (watch vs phone) land in DIFFERENT buckets so the * coarse writer's awake/in-bed blocks never blend into the granular * writer's night. Rows without a device-type keep the bare source as their * key — single-writer sources and legacy rows collapse exactly as before. */ function writerKeyOf(r: SleepStageRow): string { const src = r.source ?? NO_SOURCE; const dev = r.deviceType; return dev ? `${src}${WRITER_KEY_SEP}${dev}` : src; } /** The source part of a writer key (for ladder ranking + display). */ function sourceOfWriterKey(key: string): string { const i = key.indexOf(WRITER_KEY_SEP); return i === -1 ? key : key.slice(0, i); } /** * Count of DISTINCT granular stages (CORE / DEEP / REM) in a row set — the * stage-richness score of a writer's night. 3 = full hypnogram, 0 = coarse * (bare ASLEEP / AWAKE / IN_BED only). */ function granularStageCount(rows: readonly SleepStageRow[]): number { const seen = new Set(); for (const r of rows) { const stage = r.sleepStage; if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) seen.add(stage); } return seen.size; } /** * Pick the one canonical WRITER for a session. STAGE RICHNESS wins first: * the writer carrying the most distinct granular stages (CORE / DEEP / REM) * always beats a coarser one, so neither a parallel coarse export of the * same night (e.g. Apple Health's AWAKE/ASLEEP blocks alongside WHOOP's * per-stage rows) nor a second writer app behind the same source (the * phone's in-bed detection next to the watch's stages) can mask the * fullest hypnogram available for the night. Among equally rich writers * the user's `sleep` source-priority ladder decides (lowest ladder index = * highest priority); writers whose source is absent from the ladder fall * back to "most asleep minutes", then a stable key tiebreak. Single-writer * (or source-less) sessions resolve to that one bucket with no work. */ function pickSessionWriter( rows: SleepStageRow[], sleepLadder: readonly MeasurementSource[], ): string { // Group rows by writer, then sum each writer's asleep minutes with the // granular-over-bare rule so a writer that stores BOTH a bare ASLEEP // aggregate and the granular breakdown is not over-weighted in the tiebreak. const rowsByWriter = new Map(); for (const r of rows) { const key = writerKeyOf(r); const list = rowsByWriter.get(key) ?? []; list.push(r); rowsByWriter.set(key, list); } const asleepByWriter = new Map(); for (const [key, writerRows] of rowsByWriter) { const minutes = asleepMinutesOf(writerRows); if (minutes > 0) asleepByWriter.set(key, minutes); } // No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set // of all present writers so the session still resolves to a single bucket. let writers = asleepByWriter.size > 0 ? [...asleepByWriter.keys()] : [...rowsByWriter.keys()]; // Richness gate: keep only the writers tied for the MOST distinct granular // stages. A 3-stage hypnogram beats a 1-stage partial beats a coarse // ASLEEP-only export; coarse writers survive only when NO writer carries a // granular stage for the session. const richnessOf = new Map(); let maxRichness = 0; for (const key of writers) { const score = granularStageCount(rowsByWriter.get(key) ?? []); richnessOf.set(key, score); if (score > maxRichness) maxRichness = score; } if (maxRichness > 0) { writers = writers.filter((key) => richnessOf.get(key) === maxRichness); } if (writers.length <= 1) return writers[0] ?? NO_SOURCE; const rankOf = (key: string): number => { const i = sleepLadder.indexOf(sourceOfWriterKey(key) as MeasurementSource); return i === -1 ? Number.MAX_SAFE_INTEGER : i; }; return writers.sort((a, b) => { const ra = rankOf(a); const rb = rankOf(b); if (ra !== rb) return ra - rb; // Neither (or both) on the ladder — most asleep minutes wins, then key. const ma = asleepByWriter.get(a) ?? 0; const mb = asleepByWriter.get(b) ?? 0; if (ma !== mb) return mb - ma; return a < b ? -1 : 1; })[0]; } /** * Group raw per-stage rows into per-night totals. Stages are clustered into * sleep SESSIONS by time gap (a > `SESSION_GAP_MS` gap starts a new session), * each session keyed by the LOCAL WAKE DAY of its last segment, then collapsed * to ONE source via the user's `sleep` priority ladder before summing. * * Pure — the caller does the bounded DB read. `priorityJson` is the user's * persisted `sourcePriorityJson` (or null for the defaults). Nights are * returned sorted ascending by key so the last element is the most recent * night. */ export function reconstructSleepNights( rows: SleepStageRow[], tz: string, priorityJson: unknown = null, ): SleepNight[] { if (rows.length === 0) return []; const sleepLadder = getSourceLadder( parseSourcePriority(priorityJson), "sleep", ); // Cluster into sessions by the gap between a segment's START and the latest // END seen so far in the current session. `measuredAt` is the segment END; // the segment START is `end − value minutes`. A new session begins only when // the next segment STARTS more than SESSION_GAP_MS after the running end — // so a single long stage block (a 4 h CORE) does NOT split a night, and two // sources' interleaved/overlapping rows for the same night stay together // (their gap is ≤ 0). Gaps are absolute-time, so the clustering is DST-immune. // Sort by START so contiguous segments compare end-to-start in order. 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 sessions: 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) { sessions.push(current); current = []; sessionEnd = Number.NEGATIVE_INFINITY; } current.push(r); if (end > sessionEnd) sessionEnd = end; } if (current.length > 0) sessions.push(current); // Two sessions can land on the same wake day (e.g. a genuine overnight plus // a same-day nap); collect each session's canonical-writer rows under one // wake-day key. The rows are grouped PER SESSION (not flattened) so the // granular-over-bare gate and the asleep total are decided per session — a // bare-only nap keeps its minutes even when the overnight session is // granular (the v1.11.5 merged-night nap under-count fix). The FULL // session row set rides along because the in-bed envelope spans every // writer, not just the winner (see the module doc's writer-de-dup // exception). const byNight = new Map< string, Array<{ pool: SleepStageRow[]; all: SleepStageRow[] }> >(); for (const session of sessions) { const canonical = pickSessionWriter(session, sleepLadder); 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); const list = byNight.get(key) ?? []; list.push({ pool, all: session }); byNight.set(key, list); } const nights: SleepNight[] = []; for (const [night, nightSessions] of byNight) { let inBed = 0; let awake = 0; let sawInBed = false; let sawAwake = false; let asleep = 0; // `nightSessions` and each session pool are non-empty by construction: a // key is only added with a `pool` that has ≥ 1 row (the source-filter falls // back to the full session when the canonical filter empties it), so // `nightSessions[0].pool[0]` is safe. Unlike `reconstructSleepSessions`, // this function never applies the granular-over-bare filter to the indexed // pool, so it cannot empty the array here. Keep that invariant on future // edits. let latest = nightSessions[0].pool[0].measuredAt; const stages: Partial> = {}; for (const { pool: sessionRows, all } of nightSessions) { // In-bed: union envelope across ALL writers in the session (sessions // are > 3 h apart, so per-session envelopes never overlap and summing // them per wake day is exact). const envelope = inBedEnvelopeMinutes(all); if (envelope !== null) { inBed += envelope; sawInBed = true; } // v1.11.5 — decide the granular-over-bare gate PER SESSION. A granular // overnight does not strip a bare-only nap's minutes, and the per-stage // `stages` map drops the redundant bare ASLEEP aggregate only for the // session that also carries the granular partition. const sawGranular = sawGranularStage(sessionRows); // Asleep total: sum per session so a granular overnight + a bare-only // nap both contribute (the merged-night nap under-count fix). asleep += asleepMinutesOf(sessionRows); for (const r of sessionRows) { const minutes = Number.isFinite(r.value) ? r.value : 0; if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt; const stage = r.sleepStage; // Drop the redundant bare ASLEEP aggregate (and stage-less rows) from // the per-stage breakdown when this session has the granular partition, // so `stages` is never the granular set PLUS its ~equal bare twin. if (isRedundantBareAsleep(stage, sawGranular)) continue; if (stage) { stages[stage] = (stages[stage] ?? 0) + minutes; } if (stage === "AWAKE") { awake += minutes; sawAwake = true; } } } nights.push({ night, measuredAt: latest, asleepMinutes: asleep, inBedMinutes: sawInBed ? inBed : null, awakeMinutes: sawAwake ? awake : null, stages, }); } 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 row exists anywhere in the session, * else null. Union envelope across ALL writers (not just the * canonical one) — see the module doc's writer-de-dup exception. */ 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 = pickSessionWriter(session, sleepLadder); const kept = session.filter((r) => writerKeyOf(r) === canonical); const pool = kept.length > 0 ? kept : session; // v1.11.5 — drop the redundant bare ASLEEP aggregate (and stage-less // rows) from the hypnogram segments AND the per-stage breakdown when the // session also carries the granular CORE/DEEP/REM partition, so the // hypnogram never draws a double-height ASLEEP lane on top of the // granular stages and the `stages` map is the granular set OR the bare // aggregate, never both. const sawGranular = sawGranularStage(pool); const segments = pool .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular)) .map(segmentOf) .sort((a, b) => a.start.getTime() - b.start.getTime()); // The granular-over-bare filter can empty a non-empty pool (a session whose // only rows are the redundant bare ASLEEP aggregate / stage-less twins of a // granular partition that did not survive into this pool). A session with no // renderable/scorable segment contributes nothing — skip it before indexing // `segments[0]`. This keeps `reconstructSleepSessions` total: it must NEVER // throw on a shape the dedup empties, so `GET /api/sleep/night` (and every // other consumer) returns a valid empty night, never a 500. if (segments.length === 0) continue; let awake = 0; 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 === "AWAKE") { awake += seg.minutes; sawAwake = true; } } // In-bed: union envelope across ALL writers in the RAW session — the // canonical (winning) writer often carries no IN_BED row at all (a // watch's stage export) while the losing writer holds the night's // only in-bed window (the phone's bedtime detection). The hypnogram // `segments` + `stages` stay winner-only. const inBedEnvelope = inBedEnvelopeMinutes(session); const asleep = asleepMinutesOf(pool); const canonicalSource = sourceOfWriterKey(canonical); sessions.push({ night: userDayKey(latest, tz), source: canonicalSource === NO_SOURCE ? null : (canonicalSource as MeasurementSource), start: earliest, end: latest, asleepMinutes: asleep, inBedMinutes: inBedEnvelope, 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 * (minutes). Drop-in replacement for the per-stage `summaries. * SLEEP_DURATION` the slim slice produced. Null when no night has any * asleep minutes in the supplied rows. */ summary: DataSummary; /** The most recent night (for the dashboard/series headline), or null. */ latestNight: SleepNight | null; } /** * Reconstruct nights from raw rows and summarise the per-night asleep * totals. Nights with zero asleep minutes are dropped (an IN_BED-only or * AWAKE-only fragment is not a scorable night). The returned `summary` * uses one DataPoint per night so `summarize()`'s windowed avg7 / avg30 * become "average nightly sleep over the trailing N days". */ export function summarizeSleepNights( rows: SleepStageRow[], tz: string, priorityJson: unknown = null, ): SleepNightSummary { const nights = reconstructSleepNights(rows, tz, priorityJson).filter( (n) => n.asleepMinutes > 0, ); const dataPoints = nights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes, })); return { summary: summarize(dataPoints), latestNight: nights.length > 0 ? nights[nights.length - 1] : null, }; }