feat(sleep): add per-session reconstruction and the main-night/nap convention

Add reconstructSleepSessions — the session-level sibling to
reconstructSleepNights that keeps each sleep session separate (rather
than merging same-wake-day sessions under one key) and resolves every
stage row to its absolute span (start = measuredAt − duration), picks
one canonical source per session, and counts mid-sleep awakenings.

Add pickMainNightAndNaps to make the nap convention explicit: the main
night is the session with the most asleep minutes; every other session
on the same wake day is a nap, surfaced separately and never folded into
the headline.

These views are pure functions over existing per-stage rows — no schema
change, no new measurement type. They back the per-night list collapse,
the analytics reconciliation, and the hypnogram.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:56:20 +02:00
1 parent 60462b0f12
commit d5e67307e5
2 files changed
+342

No files matched your search

@@ -5,6 +5,8 @@ import type {
} from "@/generated/prisma/client";
import {
reconstructSleepNights,
reconstructSleepSessions,
pickMainNightAndNaps,
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
@@ -243,6 +245,130 @@ describe("reconstructSleepNights", () => {
});
});
describe("reconstructSleepSessions", () => {
it("resolves each stage row to start = end − duration", () => {
const rows: SleepStageRow[] = [
// 240-min CORE ending 03:00 UTC → starts 23:00 the previous instant.
row("2026-06-04T03:00:00.000Z", "CORE", 240),
row("2026-06-04T04:30:00.000Z", "REM", 90), // 03:00 → 04:30
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
const s = sessions[0];
expect(s.start.toISOString()).toBe("2026-06-03T23:00:00.000Z");
expect(s.end.toISOString()).toBe("2026-06-04T04:30:00.000Z");
// Segments carry their own start/end span.
const core = s.segments.find((seg) => seg.stage === "CORE");
expect(core?.start.toISOString()).toBe("2026-06-03T23:00:00.000Z");
expect(core?.end.toISOString()).toBe("2026-06-04T03:00:00.000Z");
expect(s.asleepMinutes).toBe(330);
});
it("splits a daytime nap into its own session, separate from the night", () => {
const rows: SleepStageRow[] = [
row("2026-06-03T13:00:00.000Z", "CORE", 45), // 14:15 → 15:00 nap
// Overnight contiguous block.
row("2026-06-03T21:00:00.000Z", "CORE", 200),
row("2026-06-03T23:00:00.000Z", "DEEP", 120),
row("2026-06-04T01:00:00.000Z", "REM", 120),
row("2026-06-04T04:00:00.000Z", "CORE", 180),
];
const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
expect(sessions).toHaveLength(2);
// Sorted ascending by start: nap first, overnight second.
expect(sessions[0].asleepMinutes).toBe(45);
expect(sessions[1].asleepMinutes).toBe(620);
});
it("keeps only the canonical source's segments on a dual-source night", () => {
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 85, "APPLE_HEALTH"),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
expect(sessions[0].source).toBe("WHOOP");
// Only WHOOP's 3 segments survive; Apple Health's are dropped.
expect(sessions[0].segments).toHaveLength(3);
expect(sessions[0].asleepMinutes).toBe(420);
});
it("counts only MID-sleep AWAKE bouts as awakenings", () => {
const rows: SleepStageRow[] = [
// Leading AWAKE before sleep onset — NOT an awakening.
row("2026-06-04T00:10:00.000Z", "AWAKE", 10),
row("2026-06-04T02:00:00.000Z", "CORE", 110),
// Mid-sleep AWAKE bout — counts.
row("2026-06-04T02:15:00.000Z", "AWAKE", 15),
row("2026-06-04T04:00:00.000Z", "DEEP", 105),
// Another mid-sleep AWAKE bout — counts.
row("2026-06-04T04:10:00.000Z", "AWAKE", 10),
row("2026-06-04T06:00:00.000Z", "REM", 110),
// Trailing AWAKE after final wake — NOT an awakening.
row("2026-06-04T06:05:00.000Z", "AWAKE", 5),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
expect(sessions[0].awakenings).toBe(2);
});
it("returns no sessions for empty input", () => {
expect(reconstructSleepSessions([], "UTC")).toEqual([]);
});
});
describe("pickMainNightAndNaps", () => {
it("picks the longest-asleep session as the main night, rest as naps", () => {
// Same wake day (Berlin): a short nap + the overnight block.
const rows: SleepStageRow[] = [
row("2026-06-04T11:00:00.000Z", "CORE", 40), // 12:00 → 12:40 nap (Jun 4)
// Overnight ~22:00 Jun 3 → 06:00 Jun 4.
row("2026-06-03T21:00:00.000Z", "CORE", 180),
row("2026-06-04T00:00:00.000Z", "DEEP", 120),
row("2026-06-04T04:00:00.000Z", "REM", 180),
];
const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
const { main, naps } = pickMainNightAndNaps(sessions);
expect(main?.asleepMinutes).toBe(480);
expect(naps).toHaveLength(1);
expect(naps[0].asleepMinutes).toBe(40);
// Nap shares the main night's wake day.
expect(naps[0].night).toBe(main?.night);
});
it("returns no main when every session is IN_BED/AWAKE only", () => {
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "IN_BED", 60),
row("2026-06-04T06:00:00.000Z", "AWAKE", 60),
];
const sessions = reconstructSleepSessions(rows, "UTC");
const { main, naps } = pickMainNightAndNaps(sessions);
expect(main).toBeNull();
expect(naps).toEqual([]);
});
it("does not surface a different wake day's session as a nap", () => {
// Two overnight blocks on consecutive wake days — neither is the
// other's nap even though both land in the same input array.
const rows: SleepStageRow[] = [
// Wake day Jun 3.
row("2026-06-03T01:00:00.000Z", "CORE", 240),
row("2026-06-03T05:00:00.000Z", "DEEP", 120),
// Wake day Jun 4.
row("2026-06-04T01:00:00.000Z", "CORE", 300),
row("2026-06-04T05:00:00.000Z", "DEEP", 120),
];
const sessions = reconstructSleepSessions(rows, "UTC");
const { main, naps } = pickMainNightAndNaps(sessions);
// Jun 4 is the longer night.
expect(main?.night).toBe("2026-06-04");
expect(naps).toEqual([]);
});
});
describe("summarizeSleepNights", () => {
it("summarises per-night totals, not per-stage rows", () => {
const rows: SleepStageRow[] = [
+216
View File
@@ -47,6 +47,16 @@
* Apple Health convention (a sleep session is attributed to the date you wake
* up) and keeps the keying tz/DST-correct because it runs on the real instant.
*
* Naps vs the main night
* ----------------------
* `reconstructSleepNights` MERGES every session that lands on the same wake
* day under one key so the headline "time asleep" for a day is the day's
* total. `reconstructSleepSessions` keeps each session SEPARATE, and
* `pickMainNightAndNaps` applies the convention the UI needs: the MAIN night
* is the session with the most asleep minutes (normally the overnight block),
* and every other session on the same wake day is a NAP, surfaced separately
* and never folded into the main night's headline.
*
* Source de-dup
* -------------
* A user paired to more than one sleep source (e.g. WHOOP + Apple Health)
@@ -320,6 +330,212 @@ export function reconstructSleepNights(
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
}
/**
* One reconstructed sleep SEGMENT — a single stage row resolved to its
* absolute start / end span. `start = measuredAt − value·60_000` because
* `measuredAt` is the segment END instant (`apple-health-mapping.ts` sets
* `takenAt = endDate`). Used by the hypnogram view to lay each stage onto
* a clock-time lane.
*/
export interface SleepSegment {
stage: SleepStage | null;
/** Absolute segment start instant (end − duration). */
start: Date;
/** Absolute segment end instant (= the stored `measuredAt`). */
end: Date;
/** Duration in minutes (= the stored `value`). */
minutes: number;
}
/**
* One reconstructed sleep SESSION — a contiguous block of segments (no
* gap > `SESSION_GAP_MS`) collapsed to ONE canonical source. This is the
* unit the hypnogram renders and the nap convention separates (main night
* vs nap). Distinct from `SleepNight`, which merges same-wake-day sessions
* under one key for the headline number.
*/
export interface SleepSession {
/** Wake-day key (YYYY-MM-DD) of the session's last segment, in user tz. */
night: string;
/** The canonical ingest source whose segments were kept (null = none). */
source: MeasurementSource | null;
/** Session start instant — earliest segment start. */
start: Date;
/** Session end instant — latest segment end (the wake instant). */
end: Date;
/** Time asleep in minutes (granular-over-bare rule). */
asleepMinutes: number;
/** In-bed minutes when an IN_BED segment exists, else null. */
inBedMinutes: number | null;
/** Awake-in-bed minutes when an AWAKE segment exists, else null. */
awakeMinutes: number | null;
/** Per-stage minutes for the session (only stages the device reported). */
stages: Partial<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;
const segments = pool
.map(segmentOf)
.sort((a, b) => a.start.getTime() - b.start.getTime());
let inBed = 0;
let awake = 0;
let sawInBed = false;
let sawAwake = false;
const stages: Partial<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