Files
HealthLog/src/lib/analytics/__tests__/sleep-night.test.ts
T
Marc-André Bombeck 024da9fe96 fix(ui): granular sleep sources, viewport-safe tour, one aligned settings language
A night with coarse apple-health samples next to granular watch data
showed only awake and asleep — the per-session source pick now prefers
the source that knows its stages. Tour popovers clamp into the
viewport, wrap their footer and fall back to a bottom sheet on narrow
screens. Settings and admin sections share one alignment rule — icon
column left, everything else flush on the content column — with neutral
header icons, no duplicated section titles under the shell heading, and
every select on the account-style idiom with a visible chevron. The
dashboard's customize icon matches insights, the target-settings link
leaves the home charts, and the mood heatmap adopts the damped fills
the other mood charts already use.
2026-06-10 21:19:05 +02:00

672 lines
32 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.
import { describe, it, expect } from "vitest";
import type {
MeasurementSource,
SleepStage,
} from "@/generated/prisma/client";
import {
reconstructSleepNights,
reconstructSleepSessions,
pickMainNightAndNaps,
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
/** Build a stage row with a fixed wall-clock instant. */
function row(
iso: string,
stage: SleepStage | null,
minutes: number,
source?: MeasurementSource,
): SleepStageRow {
return {
measuredAt: new Date(iso),
sleepStage: stage,
value: minutes,
...(source ? { source } : {}),
};
}
/** Build a stage row tagged with an ingest source. */
function srcRow(
iso: string,
stage: SleepStage | null,
minutes: number,
source: MeasurementSource,
): SleepStageRow {
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes, source };
}
describe("reconstructSleepNights", () => {
it("sums asleep stages into one night, excluding IN_BED + AWAKE", () => {
// One night written as five per-stage rows (WHOOP / Apple shape), all
// on the same UTC calendar day so the night-key collapses them.
const rows: SleepStageRow[] = [
row("2026-06-04T00:00:00.000Z", "IN_BED", 480),
row("2026-06-04T00:30:00.000Z", "CORE", 240),
row("2026-06-04T02:00:00.000Z", "DEEP", 90),
row("2026-06-04T04:00:00.000Z", "REM", 80),
row("2026-06-04T05:00:00.000Z", "AWAKE", 20),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
const main = nights[0];
expect(main.night).toBe("2026-06-04");
// Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410. AWAKE excluded.
expect(main.asleepMinutes).toBe(410);
// AWAKE recorded but not counted as asleep.
expect(main.awakeMinutes).toBe(20);
// IN_BED row present → in-bed total surfaced.
expect(main.inBedMinutes).toBe(480);
expect(main.stages.CORE).toBe(240);
expect(main.stages.DEEP).toBe(90);
expect(main.stages.REM).toBe(80);
});
it("groups all stages of one night by the user's tz calendar day", () => {
// In a non-UTC zone (Auckland = UTC+12 in June) a night whose stages
// straddle UTC midnight still collapses to one local day. These
// instants are all 2026-06-04 LOCAL (12:00–17:00 UTC = 00:00–05:00
// Jun 4 Auckland) but straddle UTC midnight if keyed naively.
const rows: SleepStageRow[] = [
row("2026-06-03T12:00:00.000Z", "IN_BED", 480), // 00:00 Jun 4 NZST
row("2026-06-03T13:00:00.000Z", "CORE", 240), // 01:00 Jun 4
row("2026-06-03T15:00:00.000Z", "DEEP", 90), // 03:00 Jun 4
row("2026-06-03T17:00:00.000Z", "REM", 80), // 05:00 Jun 4
];
const nights = reconstructSleepNights(rows, "Pacific/Auckland");
expect(nights).toHaveLength(1);
expect(nights[0].night).toBe("2026-06-04");
expect(nights[0].asleepMinutes).toBe(410);
expect(nights[0].inBedMinutes).toBe(480);
});
it("treats a bare SLEEP_DURATION row (no stage) as the night total", () => {
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", null, 423),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(423);
expect(nights[0].inBedMinutes).toBeNull();
});
it("keeps a night that straddles LOCAL midnight as ONE night (HIGH)", () => {
// Berlin = UTC+2 in June. Asleep 22:30 → 06:15 LOCAL across CONTIGUOUS
// stage segments (each ends where the next begins). The stage END instants
// land on BOTH sides of local midnight, so a per-stage day key would split
// the night in two and the headline would lose the pre-midnight sleep.
// Session clustering must collapse them into one night keyed by the LOCAL
// WAKE DAY (Jun 4, the morning the user wakes).
const rows: SleepStageRow[] = [
// IN_BED spans the whole night (22:30 → 06:15 local = 465 min).
row("2026-06-04T04:15:00.000Z", "IN_BED", 465),
row("2026-06-03T21:30:00.000Z", "CORE", 60), // 22:30 → 23:30 Jun 3
row("2026-06-03T23:00:00.000Z", "DEEP", 90), // 23:30 → 01:00 (→ Jun 4)
row("2026-06-04T01:00:00.000Z", "REM", 120), // 01:00 → 03:00 Jun 4
row("2026-06-04T04:15:00.000Z", "CORE", 195), // 03:00 → 06:15 Jun 4
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(1);
// Keyed by the wake day, not the fall-asleep day.
expect(nights[0].night).toBe("2026-06-04");
// Whole night summed: CORE 60 + DEEP 90 + REM 120 + CORE 195 = 465.
expect(nights[0].asleepMinutes).toBe(465);
expect(nights[0].inBedMinutes).toBe(465);
});
it("keeps a night that straddles a DST spring-forward as one night", () => {
// Europe/Berlin springs forward 2026-03-29 02:00 → 03:00 local (UTC+1 →
// UTC+2). A night asleep ~23:00 Mar 28 → ~07:00 Mar 29 local crosses the
// skipped hour. The absolute-time gap clustering is DST-immune, and the
// wake-day key resolves on the real instant, so it stays one night keyed
// to the wake day (Mar 29).
// Contiguous segments chained in UTC across the spring-forward seam.
const rows: SleepStageRow[] = [
row("2026-03-28T22:40:00.000Z", "CORE", 100), // 23:00 → 23:40 Mar 28 (UTC+1)
row("2026-03-29T00:30:00.000Z", "DEEP", 110), // 23:40 → 01:30 Mar 29 (UTC+1)
// 01:55 UTC = 03:55 Mar 29 local AFTER the spring-forward (UTC+2).
row("2026-03-29T01:55:00.000Z", "REM", 85), // 01:30 → 03:55 Mar 29 (DST seam)
row("2026-03-29T05:00:00.000Z", "CORE", 185), // 03:55 → 07:00 Mar 29 (UTC+2)
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(1);
expect(nights[0].night).toBe("2026-03-29");
// 100 + 110 + 85 + 185 = 480 asleep minutes, summed across the DST seam.
expect(nights[0].asleepMinutes).toBe(480);
});
it("keeps a daytime nap separable from the following overnight night", () => {
// A 15:00 nap and a 19:40→06:00 overnight block are > 3 h apart, so they
// are two distinct sessions — the nap is NOT lumped into the night.
const rows: SleepStageRow[] = [
row("2026-06-03T13:00:00.000Z", "CORE", 45), // 14:15 → 15:00 Jun 3 nap
// Overnight, contiguous: ~19:40 Jun 3 → 06:00 Jun 4 local.
row("2026-06-03T21:00:00.000Z", "CORE", 200), // 19:40 → 23:00 Jun 3
row("2026-06-03T23:00:00.000Z", "DEEP", 120), // 23:00 → 01:00 Jun 4
row("2026-06-04T01:00:00.000Z", "REM", 120), // 01:00 → 03:00 Jun 4
row("2026-06-04T04:00:00.000Z", "CORE", 180), // 03:00 → 06:00 Jun 4
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(2);
// Nap keyed to its own wake day (Jun 3); overnight to Jun 4.
expect(nights[0].night).toBe("2026-06-03");
expect(nights[0].asleepMinutes).toBe(45);
expect(nights[1].night).toBe("2026-06-04");
expect(nights[1].asleepMinutes).toBe(620);
});
it("collapses a dual-source night to one canonical source (MEDIUM-1)", () => {
// WHOOP + Apple Health both report the SAME night. The default `sleep`
// ladder is WHOOP > APPLE_HEALTH > WITHINGS, so only WHOOP's stages are
// summed — no double-count, no blend.
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"),
// Apple Health's parallel rows for the same night — must be dropped.
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 85, "APPLE_HEALTH"),
srcRow("2026-06-04T04:35:00.000Z", "REM", 80, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// WHOOP only: 240 + 90 + 90 = 420, NOT the ~825 blend of both sources.
expect(nights[0].asleepMinutes).toBe(420);
expect(nights[0].stages.CORE).toBe(240);
});
it("does NOT double-count a bare ASLEEP aggregate against the granular stages (HIGH, v1.11.5)", () => {
// Apple Health writes BOTH an unspecified `ASLEEP` AGGREGATE row AND the
// granular CORE/DEEP/REM breakdown for the SAME night. The granular rows
// partition the same period the aggregate covers, so summing them would
// ~double the night (here: 480 granular + 480 bare = 960 ≈ 16 h, the
// real-data symptom). The asleep total must be the GRANULAR sum (~8 h),
// never the granular + bare blend.
const rows: SleepStageRow[] = [
// Granular breakdown — the canonical partition (8 h total).
row("2026-06-04T01:00:00.000Z", "CORE", 240), // 23:00 → 03:00
row("2026-06-04T03:00:00.000Z", "DEEP", 120), // 03:00 → 05:00
row("2026-06-04T05:00:00.000Z", "REM", 120), // 05:00 → 07:00
// Unspecified ASLEEP aggregate covering the SAME 8 h — must be dropped.
row("2026-06-04T05:00:00.000Z", "ASLEEP", 480),
// IN_BED + AWAKE never count toward asleep.
row("2026-06-04T05:00:00.000Z", "IN_BED", 510),
row("2026-06-04T04:30:00.000Z", "AWAKE", 30),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// Granular only: 240 + 120 + 120 = 480 (8 h), NOT 960 (16 h).
expect(nights[0].asleepMinutes).toBe(480);
expect(nights[0].inBedMinutes).toBe(510);
expect(nights[0].awakeMinutes).toBe(30);
// HIGH (H1): the per-stage `stages` map must NOT carry the bare ASLEEP
// aggregate alongside the granular partition — it would render a
// double-height green ASLEEP segment on top of CORE+DEEP+REM. Only the
// granular stages + IN_BED + AWAKE survive.
expect(nights[0].stages.ASLEEP).toBeUndefined();
expect(nights[0].stages.CORE).toBe(240);
expect(nights[0].stages.DEEP).toBe(120);
expect(nights[0].stages.REM).toBe(120);
expect(nights[0].stages.IN_BED).toBe(510);
expect(nights[0].stages.AWAKE).toBe(30);
});
it("includes a bare-only nap in the night total when the overnight is granular (MEDIUM-1, v1.11.5)", () => {
// Same wake day: a granular WHOOP overnight (8 h) plus a bare-ASLEEP-only
// Apple nap (45 min) more than 3 h later. Reconstructing over the MERGED
// pool would set sawGranular from the overnight and wrongly drop the
// nap's bare ASLEEP. The per-session asleep total must keep the nap.
const rows: SleepStageRow[] = [
// Overnight (WHOOP, granular): 23:00 → 07:00 = 8 h asleep.
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "WHOOP"),
srcRow("2026-06-04T07:00:00.000Z", "REM", 120, "WHOOP"),
// Afternoon nap (Apple, bare ASLEEP only): 14:00 → 14:45 = 45 min,
// well beyond the 3 h session gap from the overnight.
srcRow("2026-06-04T14:45:00.000Z", "ASLEEP", 45, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// 480 (granular overnight) + 45 (bare nap) = 525, NOT 480 (nap dropped).
expect(nights[0].asleepMinutes).toBe(525);
// The night's stages carry the granular overnight AND the nap's bare
// ASLEEP — the nap was a separate session with no granular partition, so
// its bare aggregate is its only asleep signal and survives.
expect(nights[0].stages.CORE).toBe(240);
expect(nights[0].stages.DEEP).toBe(120);
expect(nights[0].stages.REM).toBe(120);
expect(nights[0].stages.ASLEEP).toBe(45);
});
it("falls back to the bare ASLEEP row when no granular stage exists", () => {
// Legacy iOS 15- writes only the unspecified aggregate — there is no
// granular partition, so the bare ASLEEP row IS the night's total.
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "ASLEEP", 465),
row("2026-06-04T06:00:00.000Z", "IN_BED", 500),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(465);
expect(nights[0].inBedMinutes).toBe(500);
});
it("does not double-count bare+granular WITHIN one source on a dual-source night", () => {
// Both WHOOP and Apple Health report the same night, and EACH writes a
// bare ASLEEP aggregate alongside its granular breakdown. The night must
// collapse to ONE source (WHOOP by default ladder) AND count only that
// source's granular stages — no cross-source sum, no bare double-count.
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-04T04:30:00.000Z", "ASLEEP", 420, "WHOOP"), // bare WHOOP aggregate
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 85, "APPLE_HEALTH"),
srcRow("2026-06-04T04:35:00.000Z", "REM", 80, "APPLE_HEALTH"),
srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 395, "APPLE_HEALTH"), // bare Apple aggregate
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// WHOOP granular only: 240 + 90 + 90 = 420, not 840 (bare+granular) and
// not the ~1660 four-way blend of both sources' bare + granular rows.
expect(nights[0].asleepMinutes).toBe(420);
});
it("honours a per-user ladder that prefers Apple Health over WHOOP", () => {
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
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-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 80, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
// Apple Health wins under the override: 230 + 80 = 310.
expect(nights[0].asleepMinutes).toBe(310);
});
it("prefers the stage-granular source over a coarse one regardless of ladder", () => {
// Mixed night: WHOOP writes the full hypnogram (REM/CORE/DEEP/AWAKE),
// Apple Health writes only coarse AWAKE/ASLEEP blocks + IN_BED for the
// SAME night. Even with a ladder that ranks Apple Health first, the
// granular WHOOP partition must carry the night — otherwise the chart
// collapses to awake/asleep and the stages disappear.
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
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-04T04:40:00.000Z", "AWAKE", 10, "WHOOP"),
// Apple Health's coarse parallel export of the same night.
srcRow("2026-06-04T01:30:00.000Z", "ASLEEP", 110, "APPLE_HEALTH"),
srcRow("2026-06-04T01:40:00.000Z", "AWAKE", 10, "APPLE_HEALTH"),
srcRow("2026-06-04T03:30:00.000Z", "ASLEEP", 100, "APPLE_HEALTH"),
srcRow("2026-06-04T04:30:00.000Z", "ASLEEP", 60, "APPLE_HEALTH"),
srcRow("2026-06-04T04:40:00.000Z", "IN_BED", 430, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
// WHOOP's granular partition: 240 + 90 + 90 = 420.
expect(nights[0].asleepMinutes).toBe(420);
expect(nights[0].stages.REM).toBe(90);
expect(nights[0].stages.DEEP).toBe(90);
expect(nights[0].stages.CORE).toBe(240);
// The coarse source's bare ASLEEP blocks must not blend in.
expect(nights[0].stages.ASLEEP).toBeUndefined();
});
it("keeps the coarse source as fallback when no granular source exists", () => {
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:30:00.000Z", "ASLEEP", 110, "APPLE_HEALTH"),
srcRow("2026-06-04T03:30:00.000Z", "ASLEEP", 100, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(210);
});
});
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("drops the bare ASLEEP aggregate from segments + stages when granular exists (HIGH H1, v1.11.5)", () => {
// Apple-Health shape: granular CORE/DEEP/REM PLUS the unspecified ASLEEP
// aggregate covering the same period, plus IN_BED. The hypnogram must not
// draw a double-height ASLEEP lane on top of the granular stages, so the
// bare ASLEEP segment is dropped and `stages.ASLEEP` is absent.
const rows: SleepStageRow[] = [
row("2026-06-04T01:00:00.000Z", "CORE", 240),
row("2026-06-04T03:00:00.000Z", "DEEP", 120),
row("2026-06-04T05:00:00.000Z", "REM", 120),
row("2026-06-04T05:00:00.000Z", "ASLEEP", 480), // redundant aggregate
row("2026-06-04T05:00:00.000Z", "IN_BED", 510),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
const s = sessions[0];
// No ASLEEP segment survives; CORE + DEEP + REM + IN_BED do (4 segments).
expect(s.segments.some((seg) => seg.stage === "ASLEEP")).toBe(false);
expect(s.segments).toHaveLength(4);
expect(s.stages.ASLEEP).toBeUndefined();
expect(s.asleepMinutes).toBe(480); // granular sum, not 960
expect(s.inBedMinutes).toBe(510);
});
it("keeps the bare ASLEEP segment when NO granular stage exists", () => {
// Legacy ASLEEP-only night — the bare aggregate IS the timeline, so it
// must survive as a segment (otherwise the hypnogram would have nothing).
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "ASLEEP", 450),
row("2026-06-04T06:00:00.000Z", "IN_BED", 480),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
expect(sessions[0].segments.some((seg) => seg.stage === "ASLEEP")).toBe(
true,
);
expect(sessions[0].stages.ASLEEP).toBe(450);
expect(sessions[0].asleepMinutes).toBe(450);
});
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[] = [
// Night A — 2026-06-03: 200 + 100 = 300 asleep min.
row("2026-06-03T01:00:00.000Z", "CORE", 200),
row("2026-06-03T03:00:00.000Z", "DEEP", 100),
// Night B — 2026-06-04: 240 + 90 + 90 = 420 asleep min.
row("2026-06-04T01:00:00.000Z", "CORE", 240),
row("2026-06-04T03:00:00.000Z", "DEEP", 90),
row("2026-06-04T04:30:00.000Z", "REM", 90),
];
const { summary, latestNight } = summarizeSleepNights(rows, "UTC");
// count = nights, NOT stage rows (5).
expect(summary.count).toBe(2);
// latest = most-recent night total (minutes), not a single stage.
expect(summary.latest).toBe(420);
expect(summary.min).toBe(300);
expect(summary.max).toBe(420);
expect(summary.mean).toBe(360);
expect(latestNight?.night).toBe("2026-06-04");
expect(latestNight?.asleepMinutes).toBe(420);
});
it("averages ~8 h, not ~16 h, when every night carries bare+granular rows (HIGH, v1.11.5)", () => {
// Several consecutive nights, each written the real-data way: a bare
// ASLEEP aggregate PLUS the granular CORE/DEEP/REM partition for the same
// period. The 30-day-style average must reflect the granular ~8 h totals,
// not the ~16 h bare+granular blend that produced the live 16.4 h symptom.
const nightsInput: Array<[string, number, number, number]> = [
// [wake-day, core, deep, rem] — each sums to 480 min = 8 h.
["2026-06-01", 240, 120, 120],
["2026-06-02", 250, 110, 120],
["2026-06-03", 230, 130, 120],
["2026-06-04", 240, 120, 120],
];
const rows: SleepStageRow[] = [];
for (const [day, core, deep, rem] of nightsInput) {
// Each night's stages land between 01:00 and 06:00 UTC on the wake day.
rows.push(row(`${day}T02:00:00.000Z`, "CORE", core));
rows.push(row(`${day}T04:00:00.000Z`, "DEEP", deep));
rows.push(row(`${day}T06:00:00.000Z`, "REM", rem));
// The overlapping bare ASLEEP aggregate (= core+deep+rem) — must drop.
rows.push(row(`${day}T06:00:00.000Z`, "ASLEEP", core + deep + rem));
}
const { summary } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(4);
// Mean of 480 / 480 / 480 / 480 = 480 min = 8 h, NOT 960 min = 16 h.
expect(summary.mean).toBe(480);
expect((summary.mean ?? 0) / 60).toBe(8);
});
it("drops nights with zero asleep minutes (IN_BED / AWAKE only)", () => {
const rows: SleepStageRow[] = [
row("2026-06-03T23:00:00.000Z", "IN_BED", 60),
row("2026-06-03T23:30:00.000Z", "AWAKE", 60),
];
const { summary, latestNight } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(0);
expect(latestNight).toBeNull();
});
it("returns an empty summary for no rows", () => {
const { summary, latestNight } = summarizeSleepNights([], "UTC");
expect(summary.count).toBe(0);
expect(summary.latest).toBeNull();
expect(latestNight).toBeNull();
});
it("the latest night is the most-recent COMPLETE midnight-spanning night", () => {
// The headline reads `latestNight.asleepMinutes`. With a midnight-spanning
// last night, the old per-stage keying would have made the latest "night"
// the post-midnight fragment only. After the fix the latest night carries
// the full asleep total.
const rows: SleepStageRow[] = [
// Older complete night → Jun 3 (contiguous).
row("2026-06-02T23:00:00.000Z", "CORE", 200), // 21:00 → 01:00 Jun 3
row("2026-06-03T01:00:00.000Z", "DEEP", 120), // 01:00 → 03:00 Jun 3
// Last night, asleep before local midnight → Jun 4 (contiguous).
row("2026-06-03T21:00:00.000Z", "CORE", 150), // 20:30 → 23:00 Jun 3
row("2026-06-03T23:30:00.000Z", "DEEP", 150), // 23:00 → 01:30 Jun 4
row("2026-06-04T04:00:00.000Z", "REM", 270), // 23:30 → 06:00 Jun 4
];
const { summary, latestNight } = summarizeSleepNights(rows, "Europe/Berlin");
expect(summary.count).toBe(2);
expect(latestNight?.night).toBe("2026-06-04");
// Full last night = 150 + 150 + 270 = 570 (not just a post-midnight slice).
expect(latestNight?.asleepMinutes).toBe(570);
expect(summary.latest).toBe(570);
});
});
// A4 — the Insights sleep AVERAGE must come from the deduped per-night totals,
// never the raw per-stage sum that double-counts a bare ASLEEP aggregate against
// its granular twin (and folds IN_BED / AWAKE in on top). The ~20.3 h symptom is
// what a single night's stage rows summed without dedup produces.
describe("sleep average dedup (A4)", () => {
it("one source emits BOTH bare ASLEEP + granular for the same span → dedup total, not the sum", () => {
// Apple-Health-style double write for ONE overnight session: the bare
// ASLEEP aggregate (480) PLUS the granular CORE/DEEP/REM partition (also
// 480), plus IN_BED + AWAKE. Summed raw this is ~1490 min (~24.8 h); the
// deduped night total is the granular 480 min (8 h).
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480, "APPLE_HEALTH"), // bare aggregate
row("2026-06-04T02:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "APPLE_HEALTH"),
row("2026-06-04T06:00:00.000Z", "REM", 120, "APPLE_HEALTH"),
row("2026-06-04T06:30:00.000Z", "IN_BED", 470, "APPLE_HEALTH"),
row("2026-06-04T03:00:00.000Z", "AWAKE", 20, "APPLE_HEALTH"),
];
const { summary } = summarizeSleepNights(rows, "UTC");
// Granular partition wins: 240 + 120 + 120 = 480 min — NOT 480 + 480 + …
expect(summary.latest).toBe(480);
expect(summary.max).toBe(480);
// One night, not 6 stage rows.
expect(summary.count).toBe(1);
});
it("multi-source bare+granular collapses to the canonical source, no cross-source sum", () => {
// WHOOP granular + Apple bare-only for the SAME night. The canonical source
// (WHOOP, top of the sleep ladder) wins; the night total is WHOOP's dedup
// total, never WHOOP + Apple summed.
const rows: SleepStageRow[] = [
// WHOOP granular — 240 + 120 + 120 = 480.
row("2026-06-04T02:00:00.000Z", "CORE", 240, "WHOOP"),
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "WHOOP"),
row("2026-06-04T06:00:00.000Z", "REM", 120, "WHOOP"),
// Apple bare aggregate for the same span — must NOT add on top.
row("2026-06-04T06:00:00.000Z", "ASLEEP", 470, "APPLE_HEALTH"),
];
const { summary } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(1);
expect(summary.latest).toBe(480); // WHOOP only, not 480 + 470.
});
});
// A5 — `reconstructSleepSessions` must never throw on a session the dedup
// empties (the unguarded `segments[0]` access). A read returns a valid empty
// night, never a 500.
describe("reconstructSleepSessions total-safety (A5)", () => {
it("never throws on an IN_BED/AWAKE-only session and yields no main night", () => {
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "IN_BED", 60),
row("2026-06-04T06:00:00.000Z", "AWAKE", 60),
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
const sessions = reconstructSleepSessions(rows, "UTC");
// Every returned session has at least one renderable segment.
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
expect(pickMainNightAndNaps(sessions).main).toBeNull();
});
it("skips a session that empties after the granular-over-bare filter, never throws", () => {
// A session whose canonical pool, after dropping the redundant bare ASLEEP
// aggregate / stage-less twins under `sawGranular`, has zero renderable
// segments. The DEEP granular row marks the session granular; the bare +
// stage-less rows are then filtered out. The granular row survives here, so
// assert the load-bearing contract: no throw, and every returned session is
// non-empty.
const rows: SleepStageRow[] = [
row("2026-06-04T02:00:00.000Z", "DEEP", 1), // tiny granular marker
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480), // redundant bare twin
row("2026-06-04T06:00:00.000Z", null, 480), // stage-less twin
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
});
it("never throws on a stage-less-only session", () => {
// Legacy / manual stage-less rows only — no granular partition, so they are
// the fallback signal and survive the filter; still must not throw.
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", null, 480),
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
});
});