Files
HealthLog/src/lib/sleep/__tests__/reconstruct-timeline.test.ts
T
Marc-André Bombeck 290f46fcda fix(sleep): label Polar nights reconstructed and emit their awake time
Polar exposes only per-stage duration totals, so its mapper already
reconstructs a contiguous CORE-DEEP-REM order exactly like WHOOP and flags
each segment reconstructed. The night reader's honesty allow-list only held
WHOOP, so a Polar-won night was served with reconstructed:false — presenting
a synthetic stage order to web and iOS as if it were the device's measured
timing. Add POLAR to the allow-list so the layout is labelled approximate.

Polar's total_interruption_duration carried the night's awake time but was
dropped, so every Polar night read as fully consolidated (awakeMinutes:null,
awakenings:0) with overstated asleep-vs-in-bed efficiency. Lay it as a
leading AWAKE segment so the asleep stages partition the remaining window and
the reader surfaces real awake time.

WHOOP and Polar independently implemented the same contiguous-timeline walk.
Extract a shared reconstructContiguousSleepTimeline helper under src/lib/sleep
and call it from both; each mapper keeps only its field normalisation. Oura's
measured 5-min hypnogram is a different algorithm and is left untouched.
2026-06-15 01:19:25 +02:00

91 lines
3.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { reconstructContiguousSleepTimeline } from "../reconstruct-timeline";
describe("reconstructContiguousSleepTimeline", () => {
const onset = Date.parse("2026-06-09T21:00:00.000Z");
const end = Date.parse("2026-06-10T05:00:00.000Z");
it("lays stages contiguously from onset, each ending at its own instant", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
{ durationMs: 30 * 60_000, stage: "DEEP", fieldTag: "sleep_deep" },
{ durationMs: 90 * 60_000, stage: "REM", fieldTag: "sleep_rem" },
],
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
const core = rows.find((r) => r.sleepStage === "CORE")!;
const deep = rows.find((r) => r.sleepStage === "DEEP")!;
const rem = rows.find((r) => r.sleepStage === "REM")!;
expect(core.value).toBe(60);
expect(core.measuredAt.getTime()).toBe(onset + 60 * 60_000);
expect(deep.measuredAt.getTime()).toBe(onset + 90 * 60_000);
expect(rem.measuredAt.getTime()).toBe(onset + 180 * 60_000);
// Distinct instants → ordered hypnogram, not a shared right edge.
expect(new Set(rows.map((r) => r.measuredAt.getTime())).size).toBe(3);
});
it("flags every laid segment reconstructed and keys an indexed externalId", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: 15 * 60_000, stage: "AWAKE", fieldTag: "sleep_awake" },
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
],
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
expect(rows.every((r) => r.reconstructed === true)).toBe(true);
expect(rows[0].externalId).toBe("night:seg:sleep_awake:0");
expect(rows[1].externalId).toBe("night:seg:sleep_core:1");
});
it("skips non-positive, null, undefined and non-finite durations", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: 0, stage: "AWAKE", fieldTag: "sleep_awake" },
{ durationMs: null, stage: "CORE", fieldTag: "sleep_core" },
{ durationMs: undefined, stage: "DEEP", fieldTag: "sleep_deep" },
{ durationMs: Number.NaN, stage: "REM", fieldTag: "sleep_rem" },
{ durationMs: 30 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
],
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
expect(rows).toHaveLength(1);
// The only laid stage keeps index 0 (skipped stages do not consume indices).
expect(rows[0].externalId).toBe("night:seg:sleep_core:0");
expect(rows[0].measuredAt.getTime()).toBe(onset + 30 * 60_000);
});
it("appends a single IN_BED envelope at the given END instant", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }],
inBed: {
durationMs: end - onset,
measuredAt: new Date(end),
fieldTag: "sleep_in_bed",
},
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
const inBed = rows.find((r) => r.sleepStage === "IN_BED")!;
expect(inBed.measuredAt.getTime()).toBe(end);
expect(inBed.value).toBe(480); // 8 h
// IN_BED is an envelope, not a placed segment — no reconstructed flag / id.
expect(inBed.reconstructed).toBeUndefined();
expect(inBed.externalId).toBeUndefined();
});
it("omits IN_BED when its duration is missing or non-positive", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }],
inBed: { durationMs: 0, measuredAt: new Date(end), fieldTag: "sleep_in_bed" },
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
expect(rows.find((r) => r.sleepStage === "IN_BED")).toBeUndefined();
});
});