mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
643 lines
27 KiB
TypeScript
643 lines
27 KiB
TypeScript
/**
|
||
* 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.
|
||
*
|
||
* Source 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 source using the user's `sleep` source-priority ladder
|
||
* (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > WITHINGS): pick the
|
||
* highest-ranked source present in the session, falling back to the source
|
||
* with the most asleep minutes (then source name) when none of the session's
|
||
* sources sits on the ladder. Only that source's segments are summed — sources
|
||
* are never blended within a night. This mirrors the per-(type, day) collapse
|
||
* the rest of the v1.11.x numeric surfaces apply.
|
||
*
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/** 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, else null. */
|
||
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 device reported). */
|
||
stages: Partial<Record<SleepStage, number>>;
|
||
}
|
||
|
||
/** Sentinel for rows that carry no source — collapses to one bucket. */
|
||
const NO_SOURCE = "__none__";
|
||
|
||
/**
|
||
* Pick the one canonical source for a session. The user's `sleep`
|
||
* source-priority ladder wins (lowest ladder index = highest priority).
|
||
* Sources absent from the ladder fall back to "most asleep minutes", then
|
||
* a stable source-name tiebreak. Single-source (or source-less) sessions
|
||
* resolve to that one bucket with no work.
|
||
*/
|
||
function pickSessionSource(
|
||
rows: SleepStageRow[],
|
||
sleepLadder: readonly MeasurementSource[],
|
||
): string {
|
||
// Group rows by source, then sum each source's asleep minutes with the
|
||
// granular-over-bare rule so a source that writes BOTH a bare ASLEEP
|
||
// aggregate and the granular breakdown is not over-weighted in the tiebreak.
|
||
const rowsBySource = new Map<string, SleepStageRow[]>();
|
||
for (const r of rows) {
|
||
const src = r.source ?? NO_SOURCE;
|
||
const list = rowsBySource.get(src) ?? [];
|
||
list.push(r);
|
||
rowsBySource.set(src, list);
|
||
}
|
||
const asleepBySource = new Map<string, number>();
|
||
for (const [src, srcRows] of rowsBySource) {
|
||
const minutes = asleepMinutesOf(srcRows);
|
||
if (minutes > 0) asleepBySource.set(src, minutes);
|
||
}
|
||
// No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set
|
||
// of all present sources so the session still resolves to a single bucket.
|
||
const sources =
|
||
asleepBySource.size > 0
|
||
? [...asleepBySource.keys()]
|
||
: [...new Set(rows.map((r) => r.source ?? NO_SOURCE))];
|
||
if (sources.length <= 1) return sources[0] ?? NO_SOURCE;
|
||
|
||
const rankOf = (src: string): number => {
|
||
const i = sleepLadder.indexOf(src as MeasurementSource);
|
||
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||
};
|
||
return sources.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 name.
|
||
const ma = asleepBySource.get(a) ?? 0;
|
||
const mb = asleepBySource.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-source 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).
|
||
const byNight = new Map<string, SleepStageRow[][]>();
|
||
for (const session of sessions) {
|
||
const canonical = pickSessionSource(session, sleepLadder);
|
||
const kept = session.filter((r) => (r.source ?? NO_SOURCE) === 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);
|
||
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][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][0].measuredAt;
|
||
const stages: Partial<Record<SleepStage, number>> = {};
|
||
for (const sessionRows of nightSessions) {
|
||
// 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 === "IN_BED") {
|
||
inBed += minutes;
|
||
sawInBed = true;
|
||
} else 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 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<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 = pickSessionSource(session, sleepLadder);
|
||
const kept = session.filter((r) => (r.source ?? NO_SOURCE) === 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 inBed = 0;
|
||
let awake = 0;
|
||
let sawInBed = false;
|
||
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 === "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
|
||
* (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,
|
||
};
|
||
}
|