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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:19:25 +02:00
1 parent a27247f93c
commit 290f46fcda
7 files changed
+351 -86

No files matched your search

@@ -399,6 +399,22 @@ describe("reconstructSleepSessions", () => {
expect(new Set(ends).size).toBe(ends.length);
});
it("flags a Polar-won night as reconstructed (synthetic stage order)", () => {
// Polar exposes only per-stage duration totals, so its mapper reconstructs
// a contiguous CORE→DEEP→REM order exactly like WHOOP. The reader must label
// such a night reconstructed so the UI never presents the synthetic order as
// measured timing.
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "POLAR"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "POLAR"),
srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "POLAR"),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
expect(sessions[0].source).toBe("POLAR");
expect(sessions[0].reconstructed).toBe(true);
});
it("does NOT flag an Apple-Health night as reconstructed (measured timeline)", () => {
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
+6 -4
View File
@@ -285,13 +285,15 @@ const NO_SOURCE = "__none__";
* Sources whose per-segment stage ORDER is synthesised, not measured. WHOOP's
* v2 API exposes only per-stage duration totals (no onset timestamps), so the
* ingest mapper reconstructs a contiguous timeline in a fixed physiological
* order (`whoop/client.ts` `mapSleep`). A session won by such a source carries
* `reconstructed: true` so the UI labels it as an approximate layout. Apple
* Health / Withings / Fitbit all store a real per-segment series and stay off
* this set.
* order (`whoop/client.ts` + `polar/client.ts` `mapSleep`, both via the shared
* `reconstructContiguousSleepTimeline` builder). A session won by such a source
* carries `reconstructed: true` so the UI labels it as an approximate layout.
* Apple Health / Oura / Withings / Fitbit all store a real per-segment series
* and stay off this set.
*/
const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet<string> = new Set<string>([
"WHOOP",
"POLAR",
]);
/**
+47
View File
@@ -265,6 +265,53 @@ describe("mapSleep", () => {
).toBe(endMs);
});
it("emits an AWAKE segment from total_interruption_duration", () => {
const start = "2026-06-09T23:00:00+02:00"; // 21:00 UTC
const s: PolarSleep = {
date: "2026-06-10",
sleep_start_time: start,
sleep_end_time: "2026-06-10T07:00:00+02:00",
total_interruption_duration: 900, // 15 min awake
light_sleep: 3600, // 60 min
deep_sleep: 1800, // 30 min
rem_sleep: 5400, // 90 min
sleep_score: 82,
};
const mapped = mapSleep(s);
const onset = Date.parse(start);
// AWAKE is laid first (leading settling-in block), so the asleep stages
// partition the rest of the window and the night reader can surface real
// awake time + efficiency rather than a fully consolidated night.
const awake = mapped.find((m) => m.sleepStage === "AWAKE")!;
expect(awake).toBeDefined();
expect(awake.value).toBe(15);
expect(awake.reconstructed).toBe(true);
expect(awake.externalId).toBe("sleep:2026-06-10:seg:sleep_awake:0");
// AWAKE ends at +15m; CORE then starts there and is index 1.
expect(awake.measuredAt.getTime()).toBe(onset + 15 * 60_000);
const core = mapped.find((m) => m.sleepStage === "CORE")!;
expect(core.externalId).toBe("sleep:2026-06-10:seg:sleep_core:1");
expect(core.measuredAt.getTime()).toBe(onset + (15 + 60) * 60_000);
});
it("omits AWAKE when no interruption time is reported", () => {
const mapped = mapSleep({
date: "2026-06-10",
sleep_start_time: "2026-06-09T23:00:00+02:00",
sleep_end_time: "2026-06-10T07:00:00+02:00",
light_sleep: 3600,
deep_sleep: 1800,
rem_sleep: 5400,
});
expect(mapped.find((m) => m.sleepStage === "AWAKE")).toBeUndefined();
// CORE stays index 0 when AWAKE is absent.
expect(mapped.find((m) => m.sleepStage === "CORE")!.externalId).toBe(
"sleep:2026-06-10:seg:sleep_core:0",
);
});
it("falls back to a midnight-UTC anchor when the window is missing", () => {
const mapped = mapSleep({
date: "2026-06-10",
+33 -36
View File
@@ -22,6 +22,7 @@
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
const POLAR_API_BASE = "https://www.polaraccesslink.com";
@@ -481,51 +482,47 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs;
const stages: Array<
[number | null | undefined, NonNullable<MappedMeasurement["sleepStage"]>, string]
[number | null | undefined, "CORE" | "DEEP" | "REM" | "AWAKE", string]
> = [
// AWAKE first — Polar reports the night's total interruption time, but no
// per-stage onset. Lay it as a leading settling-in/awake block so the asleep
// stages partition the remaining window and the night reader can surface a
// real awakeMinutes / efficiency rather than reading the night as fully
// consolidated. Matches WHOOP's leading-AWAKE ordering.
[s.total_interruption_duration, "AWAKE", "sleep_awake"],
[s.light_sleep, "CORE", "sleep_core"],
[s.deep_sleep, "DEEP", "sleep_deep"],
[s.rem_sleep, "REM", "sleep_rem"],
];
if (haveWindow) {
// Reconstructed timeline: lay each asleep stage contiguously from onset, one
// timed row per segment ending at the running cursor. The order is
// synthetic (Polar gives no per-stage onset), so flag every segment.
let cursor = startMs;
let segIndex = 0;
for (const [sec, stage, fieldTag] of stages) {
if (typeof sec !== "number" || sec <= 0) continue;
const segEnd = cursor + sec * 1000;
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
fieldTag,
// Reconstructed timeline: lay each stage contiguously from onset, one timed
// row per segment ending at the running cursor. The order is synthetic
// (Polar gives no per-stage onset), so the shared builder flags every
// segment reconstructed; the same algorithm backs WHOOP's `mapSleep`.
out.push(
...reconstructContiguousSleepTimeline({
startMs,
stages: stages.map(([sec, stage, fieldTag]) => ({
durationMs:
typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec,
stage,
fieldTag,
})),
// IN_BED — single envelope row over the whole sleep window, stamped at
// the sleep END so the in-bed reader resolves the span back to
// [start, end].
inBed: {
durationMs: endMs - startMs,
measuredAt: new Date(endMs),
fieldTag: "sleep_in_bed",
},
// Indexed externalId keeps the several segment rows of one night
// distinct under userId_type_source_externalId.
externalId: `sleep:${s.date}:seg:${fieldTag}:${segIndex}`,
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
segIndex += 1;
}
// IN_BED — single envelope row over the whole sleep window, stamped at the
// sleep END so the in-bed reader resolves the span back to [start, end].
const inBedSec = (endMs - startMs) / 1000;
if (inBedSec > 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBedSec * SEC_TO_MIN),
unit: "minutes",
measuredAt: new Date(endMs),
fieldTag: "sleep_in_bed",
sleepStage: "IN_BED",
});
}
externalIdFor: (fieldTag, index) =>
`sleep:${s.date}:seg:${fieldTag}:${index}`,
}),
);
if (typeof s.sleep_score === "number") {
out.push({
@@ -0,0 +1,90 @@
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();
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Shared reconstructed-sleep-timeline builder.
*
* Some wearables (WHOOP v2, Polar) expose only per-stage DURATION totals for a
* night — no per-stage onset timestamps and no hypnogram endpoint. Stamping
* every stage total on the one sleep-END instant collapses the hypnogram into
* overlapping spans that all touch the night's right edge. Since the API never
* carries an order, both vendors RECONSTRUCT an ordered, contiguous timeline:
* lay the stages back-to-back from sleep ONSET in a fixed physiological order,
* emitting one timed row per segment with `measuredAt = that segment's END`.
*
* The ORDER is synthetic, so every laid segment is flagged `reconstructed:
* true`; the night DTO advertises it and the UI labels such a night as an
* approximate layout, never as measured stage timing. The reader's honesty
* flag (`analytics/sleep-night.ts` `RECONSTRUCTED_TIMELINE_SOURCES`) carries
* the same contract on the read side.
*
* This helper is the single source of the algorithm; each vendor `mapSleep`
* keeps only its field-name → tuple normalisation (ms vs sec, source field
* names) so the segment shape and externalId scheme can never drift between the
* two synthetic-timeline vendors. Oura's measured 5-min hypnogram is a
* different algorithm and does NOT use this helper.
*/
/** The sleep-row shape both vendor mappers emit. Kept structurally identical to
* each vendor's local `MappedMeasurement`; the helper returns rows assignable to
* either. */
export interface ReconstructedSleepRow {
type: "SLEEP_DURATION";
value: number;
unit: "minutes";
measuredAt: Date;
fieldTag: string;
externalId?: string;
sleepStage: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
reconstructed?: boolean;
}
/** One asleep/awake stage to lay onto the reconstructed timeline. `durationMs`
* is the stage total in milliseconds; non-positive or non-finite durations are
* skipped. */
export interface ReconstructedStage {
durationMs: number | null | undefined;
stage: "CORE" | "DEEP" | "REM" | "AWAKE";
fieldTag: string;
}
/** Optional single IN_BED envelope row over the whole sleep window. The in-bed
* reader consumes the union envelope, so this stays one row stamped at the
* sleep END (`measuredAt`) — NOT a placed segment. */
export interface ReconstructedInBed {
durationMs: number | null | undefined;
/** Sleep END instant; the in-bed reader resolves the span back to its window. */
measuredAt: Date;
fieldTag: string;
}
export interface ReconstructTimelineOptions {
/** Sleep ONSET instant; the contiguous walk starts here. */
startMs: number;
/** Stages in physiological lay-out order (e.g. AWAKE → CORE → DEEP → REM). */
stages: ReadonlyArray<ReconstructedStage>;
/** Optional IN_BED envelope row. */
inBed?: ReconstructedInBed;
/**
* Builds the indexed externalId for a laid segment so the several rows of one
* night stay distinct under `userId_type_source_externalId`. Called with the
* segment's fieldTag and its running index.
*/
externalIdFor: (fieldTag: string, index: number) => string;
}
const MS_TO_MIN = 1 / 60_000;
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* Lay the asleep/awake stages contiguously from `startMs` in the given order,
* emitting one timed `SLEEP_DURATION` row per stage (`measuredAt` = that
* segment's END), each flagged `reconstructed: true` and keyed by an indexed
* externalId. Appends a single IN_BED envelope row when `inBed` is provided.
*/
export function reconstructContiguousSleepTimeline(
opts: ReconstructTimelineOptions,
): ReconstructedSleepRow[] {
const { startMs, stages, inBed, externalIdFor } = opts;
const out: ReconstructedSleepRow[] = [];
let cursor = startMs;
let segIndex = 0;
for (const { durationMs, stage, fieldTag } of stages) {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) {
continue;
}
const segEnd = cursor + durationMs;
out.push({
type: "SLEEP_DURATION",
value: round2(durationMs * MS_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
fieldTag,
externalId: externalIdFor(fieldTag, segIndex),
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
segIndex += 1;
}
if (
inBed &&
typeof inBed.durationMs === "number" &&
Number.isFinite(inBed.durationMs) &&
inBed.durationMs > 0
) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBed.durationMs * MS_TO_MIN),
unit: "minutes",
measuredAt: inBed.measuredAt,
fieldTag: inBed.fieldTag,
sleepStage: "IN_BED",
});
}
return out;
}
+30 -46
View File
@@ -17,6 +17,7 @@
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
@@ -611,55 +612,38 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
// the remaining window.
const onset = new Date(s.start).getTime();
// Order the laid-out stages: a brief AWAKE settling-in block, then the asleep
// stages CORE → DEEP → REM. Each carries its own duration; the cursor walks
// forward so the spans are contiguous and non-overlapping.
const TIMELINE_ORDER: ReadonlyArray<{
key: keyof typeof stages;
stage: NonNullable<MappedMeasurement["sleepStage"]>;
tag: string;
}> = [
{ key: "total_awake_time_milli", stage: "AWAKE", tag: "sleep_awake" },
{ key: "total_light_sleep_time_milli", stage: "CORE", tag: "sleep_core" },
{ key: "total_slow_wave_sleep_time_milli", stage: "DEEP", tag: "sleep_deep" },
{ key: "total_rem_sleep_time_milli", stage: "REM", tag: "sleep_rem" },
];
let cursor = onset;
let segIndex = 0;
for (const { key, stage, tag } of TIMELINE_ORDER) {
const ms = stages[key];
if (typeof ms !== "number" || ms <= 0) continue;
const segEnd = cursor + ms;
out.push({
type: "SLEEP_DURATION",
value: round2(ms * MS_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
// stages CORE → DEEP → REM. Each carries its own duration; the shared builder
// walks the cursor forward so the spans are contiguous and non-overlapping,
// and lays the IN_BED envelope over the whole window. The order is synthetic
// (WHOOP gives no per-stage onset), so every laid segment is flagged
// reconstructed; the same algorithm backs Polar's `mapSleep`.
out.push(
...reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: stages.total_awake_time_milli, stage: "AWAKE", fieldTag: "sleep_awake" },
{ durationMs: stages.total_light_sleep_time_milli, stage: "CORE", fieldTag: "sleep_core" },
{
durationMs: stages.total_slow_wave_sleep_time_milli,
stage: "DEEP",
fieldTag: "sleep_deep",
},
{ durationMs: stages.total_rem_sleep_time_milli, stage: "REM", fieldTag: "sleep_rem" },
],
// IN_BED — single envelope row over the whole sleep window. `measuredAt`
// is the sleep END so `segmentOf` resolves the span back to [start, end];
// the in-bed union-envelope reader consumes the full window.
inBed: {
durationMs: stages.total_in_bed_time_milli,
measuredAt,
fieldTag: "sleep_in_bed",
},
// The sync layer keys reconstructed rows by this externalId verbatim; the
// index keeps the several rows of one night distinct under
// userId_type_source_externalId.
fieldTag: tag,
externalId: `${s.id}:seg:${tag}:${segIndex}`,
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
segIndex += 1;
}
// IN_BED — single envelope row over the whole sleep window. `measuredAt` is
// the sleep END so `segmentOf` resolves the span back to [start, end]; the
// in-bed union-envelope reader consumes the full window.
const inBedMs = stages.total_in_bed_time_milli;
if (typeof inBedMs === "number" && inBedMs > 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBedMs * MS_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag: "sleep_in_bed",
sleepStage: "IN_BED",
});
}
externalIdFor: (tag, index) => `${s.id}:seg:${tag}:${index}`,
}),
);
const need = s.score.sleep_needed;
const totalNeedMilli =