/** * 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 = 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; } /** * 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>; } /** 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(); 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(); 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(); 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> = {}; 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>; /** * 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> = {}; 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, }; }