mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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"),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user