Files
HealthLog/src/lib/analytics/sleep-night.ts
T
Marc-André Bombeck f790e14edd fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
2026-06-11 23:20:38 +02:00

804 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<SleepStage> = new Set<SleepStage>([
"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<Record<SleepStage, number>>;
}
/** 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<SleepStage>();
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<string, SleepStageRow[]>();
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<string, number>();
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<string, number>();
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<Record<SleepStage, number>> = {};
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<Record<SleepStage, number>>;
/**
* 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<Record<SleepStage, number>> = {};
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,
};
}