mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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[] = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user