fix(sleep): reconstruct an ordered WHOOP hypnogram timeline

WHOOP's v2 API returns only per-stage sleep duration totals — no onset
timestamps, no hypnogram endpoint. The mapper stamped all five stage
totals on the one sleep-END instant, so the hypnogram reconstructed
every stage as a span touching the night's right edge: bars stacked
right-aligned with no clock times whenever WHOOP was the sole source.

Lay the asleep stages back-to-back from sleep onset in a fixed
physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one
timed row per segment with measuredAt at that segment's end and a
distinct indexed externalId. IN_BED stays a single envelope row over the
whole window. The existing reader and renderer are untouched — they now
see distinct per-segment instants and place each block at its own time.

The order is synthesised, not measured, so every reconstructed row is
flagged and the night DTO carries a per-session `reconstructed` boolean
(GET /api/sleep/night, regenerated OpenAPI). The client renders the
hypnogram but labels it an approximate layout and never recomputes;
real-series sources (Apple Health, Withings, Fitbit) stay false.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:16:09 +02:00
1 parent 94fd837d48
commit bc12633569
8 files changed
+246 -30

No files matched your search

+12
View File
@@ -8982,6 +8982,11 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
reconstructed:
type: boolean
description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in
a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate
layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).
stages:
type: object
propertyNames:
@@ -9047,6 +9052,7 @@ components:
- inBedMinutes
- awakeMinutes
- awakenings
- reconstructed
- stages
- segments
additionalProperties: false
@@ -9102,6 +9108,11 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
reconstructed:
type: boolean
description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in
a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate
layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).
stages:
type: object
propertyNames:
@@ -9167,6 +9178,7 @@ components:
- inBedMinutes
- awakeMinutes
- awakenings
- reconstructed
- stages
- segments
additionalProperties: false
+9
View File
@@ -12,6 +12,11 @@
* priority ladder — so two sources' timelines never overlay. A view over the
* existing per-stage `SLEEP_DURATION` rows; no schema, no new type.
*
* `reconstructed` (per session) is true when the winning source has no
* per-stage onset timestamps and the server synthesised a contiguous timeline
* (WHOOP) — the client renders the hypnogram but labels it an approximate
* layout. A real-series source (Apple Health / Withings / Fitbit) is false.
*
* `date` omitted → the most recent night in the trailing-year window.
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
@@ -64,6 +69,10 @@ function serializeSession(s: SleepSession) {
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
awakenings: s.awakenings,
// iOS #18 — `reconstructed` is true when the source has no per-stage onset
// timestamps and the server synthesised a contiguous timeline (WHOOP). iOS
// labels the hypnogram as an approximate layout; it never recomputes.
reconstructed: s.reconstructed,
// iOS #18 — round the per-stage map too; the underlying totals sum
// second-precision segments and otherwise serialise as e.g. 88.4999.
stages: Object.fromEntries(
@@ -380,6 +380,48 @@ describe("reconstructSleepSessions", () => {
expect(sessions[0].asleepMinutes).toBe(420);
});
it("flags a WHOOP-won night as reconstructed (synthetic stage order) with clock-time-distinct segments", () => {
// The ingest mapper reconstructs an ordered, contiguous WHOOP timeline.
// The reconstructed segments carry distinct measuredAt instants, so the
// hypnogram lays them at distinct clock times (no shared right edge).
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), // 21:00→01:00
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "WHOOP"), // 01:00→03:00
srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "WHOOP"), // 03:00→05:00
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
const s = sessions[0];
expect(s.source).toBe("WHOOP");
expect(s.reconstructed).toBe(true);
// No two segments end on the same instant — not stacked on the right edge.
const ends = s.segments.map((seg) => seg.end.getTime());
expect(new Set(ends).size).toBe(ends.length);
});
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"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "APPLE_HEALTH"),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
expect(sessions[0].source).toBe("APPLE_HEALTH");
expect(sessions[0].reconstructed).toBe(false);
});
it("does NOT flag a Withings night as reconstructed (real per-segment series)", () => {
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WITHINGS"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WITHINGS"),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
expect(sessions[0].source).toBe("WITHINGS");
expect(sessions[0].reconstructed).toBe(false);
});
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
+29
View File
@@ -281,6 +281,19 @@ export interface SleepNight {
/** Sentinel for rows that carry no source — collapses to one bucket. */
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.
*/
const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet<string> = new Set<string>([
"WHOOP",
]);
/**
* Separator between the source and device-type parts of a writer key.
* `` cannot appear in a `MeasurementSource` enum literal or a
@@ -615,6 +628,17 @@ export interface SleepSession {
awakenings: number;
/** The canonical source's segments, sorted by start, for the hypnogram. */
segments: SleepSegment[];
/**
* `true` when the winning writer's stage ORDER is synthesised rather than
* measured. WHOOP's v2 API returns only per-stage duration totals — no onset
* timestamps — so the server reconstructs a contiguous timeline in a fixed
* physiological order (`whoop/client.ts` `mapSleep`). The segment positions
* are then approximate, not measured, and the UI / iOS must label the night
* as an approximate layout (never present it as measured stage timing).
* Sources that store a real per-segment series (Apple Health, Withings,
* Fitbit) stay `false`.
*/
reconstructed: boolean;
}
/** Resolve a stage row to its absolute span (start = end − duration). */
@@ -760,6 +784,11 @@ export function reconstructSleepSessions(
stages,
awakenings: countAwakenings(segments),
segments,
// The winning writer's stage order is synthetic when its source
// reconstructs the timeline (WHOOP). The UI labels such a night as an
// approximate layout; a real-series source (Apple/Withings/Fitbit) is
// measured.
reconstructed: RECONSTRUCTED_TIMELINE_SOURCES.has(canonicalSource),
});
}
return sessions.sort((a, b) => a.start.getTime() - b.start.getTime());
+5
View File
@@ -200,6 +200,11 @@ const sleepSessionResource = z.object({
inBedMinutes: z.number().int().nonnegative().nullable(),
awakeMinutes: z.number().int().nonnegative().nullable(),
awakenings: z.number().int().nonnegative(),
reconstructed: z
.boolean()
.describe(
"True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).",
),
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
segments: z.array(sleepSegmentResource),
});
+58 -9
View File
@@ -320,15 +320,64 @@ describe("mapSleep", () => {
it("maps per-stage SLEEP_DURATION rows (ms→min) with sleepStage", () => {
const rows = mapSleep(base);
const dur = rows.filter((r) => r.type === "SLEEP_DURATION");
const byStage = Object.fromEntries(dur.map((r) => [r.sleepStage, r.value]));
expect(byStage.CORE).toBe(240); // light → CORE
expect(byStage.DEEP).toBe(90); // slow-wave → DEEP
expect(byStage.REM).toBe(120);
expect(byStage.AWAKE).toBe(30);
expect(byStage.IN_BED).toBe(480);
// One asleep/awake row per reconstructed segment + one IN_BED envelope. The
// per-stage TOTALS are unchanged (the reconstruction only reorders timing).
const minutesByStage: Record<string, number> = {};
for (const r of dur) {
minutesByStage[r.sleepStage!] =
(minutesByStage[r.sleepStage!] ?? 0) + r.value;
}
expect(minutesByStage.CORE).toBe(240); // light → CORE
expect(minutesByStage.DEEP).toBe(90); // slow-wave → DEEP
expect(minutesByStage.REM).toBe(120);
expect(minutesByStage.AWAKE).toBe(30);
expect(minutesByStage.IN_BED).toBe(480);
expect(dur.every((r) => r.unit === "minutes")).toBe(true);
});
it("reconstructs an ordered, contiguous, non-overlapping per-segment timeline from sleep ONSET", () => {
const segs = mapSleep(base)
.filter((r) => r.type === "SLEEP_DURATION" && r.sleepStage !== "IN_BED")
.map((r) => ({
stage: r.sleepStage,
end: r.measuredAt.getTime(),
start: r.measuredAt.getTime() - r.value * 60_000,
reconstructed: r.reconstructed,
}))
.sort((a, b) => a.start - b.start);
// AWAKE (lead) → CORE → DEEP → REM, laid back-to-back.
expect(segs.map((s) => s.stage)).toEqual(["AWAKE", "CORE", "DEEP", "REM"]);
// First segment starts at sleep ONSET (s.start), not the END edge.
expect(segs[0].start).toBe(new Date(base.start).getTime());
// Contiguous + non-overlapping: each segment's start == previous end.
for (let i = 1; i < segs.length; i++) {
expect(segs[i].start).toBe(segs[i - 1].end);
}
// Distinct END instants — no stacking on the night's right edge.
expect(new Set(segs.map((s) => s.end)).size).toBe(segs.length);
// Every reconstructed row is flagged so the UI labels the night approximate.
expect(segs.every((s) => s.reconstructed === true)).toBe(true);
});
it("emits IN_BED as a single un-reconstructed envelope row over [start, end]", () => {
const inBed = mapSleep(base).filter((r) => r.sleepStage === "IN_BED");
expect(inBed).toHaveLength(1);
const r = inBed[0]!;
expect(r.value).toBe(480);
// measuredAt = sleep END → segmentOf resolves the span back to the window.
expect(r.measuredAt.toISOString()).toBe(base.end);
expect(r.reconstructed).toBeUndefined();
});
it("gives each reconstructed segment a distinct indexed externalId", () => {
const ids = mapSleep(base)
.filter((r) => r.reconstructed)
.map((r) => r.externalId);
expect(ids.every((id) => id?.startsWith("sleep-uuid:seg:"))).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});
it("sums SLEEP_NEED components ms→min", () => {
const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED");
expect(need?.value).toBe(490); // 450 + 30 + 10 + 0
@@ -344,9 +393,9 @@ describe("mapSleep", () => {
expect(byType.RESPIRATORY_RATE?.unit).toBe("breaths/min");
});
it("uses sleep.end as measuredAt", () => {
const row = mapSleep(base)[0]!;
expect(row.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z");
it("stamps the non-segment scores on sleep.end", () => {
const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED")!;
expect(need.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z");
});
it("emits nothing for an unscored sleep", () => {
+76 -17
View File
@@ -505,20 +505,24 @@ export interface MappedMeasurement {
fieldTag: string;
/** Per-stage sleep rows carry the SleepStage; everything else omits it. */
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
/**
* Full externalId for rows the mapper must key itself rather than letting
* the sync layer build `<resource-uuid>:<fieldTag>`. The reconstructed sleep
* segments (one row per synthetic span) need an index in the key so the
* several rows of one stage stay distinct under
* `userId_type_source_externalId`. When set the sync layer uses it verbatim.
*/
externalId?: string;
/**
* `true` on the synthesised per-segment sleep rows whose ORDER is invented:
* WHOOP's v2 API returns only per-stage duration totals (no onset
* timestamps), so the timeline is reconstructed in a fixed physiological
* order. The night is flagged so the UI labels it as an approximate layout
* and never presents it as measured stage timing.
*/
reconstructed?: boolean;
}
/** WHOOP sleep stage → HealthLog SleepStage (light→CORE, slow-wave→DEEP). */
const SLEEP_STAGE_MAP: Record<
string,
{ stage: MappedMeasurement["sleepStage"]; fieldTag: string }
> = {
total_light_sleep_time_milli: { stage: "CORE", fieldTag: "sleep_core" },
total_slow_wave_sleep_time_milli: { stage: "DEEP", fieldTag: "sleep_deep" },
total_rem_sleep_time_milli: { stage: "REM", fieldTag: "sleep_rem" },
total_awake_time_milli: { stage: "AWAKE", fieldTag: "sleep_awake" },
total_in_bed_time_milli: { stage: "IN_BED", fieldTag: "sleep_in_bed" },
};
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
@@ -589,16 +593,71 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
const out: MappedMeasurement[] = [];
const stages = s.score.stage_summary;
for (const [key, mapping] of Object.entries(SLEEP_STAGE_MAP)) {
const ms = stages[key as keyof typeof stages];
if (typeof ms !== "number") continue;
// WHOOP v2 returns only per-stage DURATION totals (no per-stage onset
// timestamps, no hypnogram endpoint). Stamping every stage total on the one
// sleep-END instant makes the hypnogram reconstruct five spans that all touch
// the night's right edge (the stacked-bar artefact). Since the API never
// carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the
// asleep 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 reconstructed row is flagged
// `reconstructed: true` and the night DTO advertises it — the UI labels it as
// an approximate layout and never presents it as measured stage timing.
//
// IN_BED stays a single envelope row spanning the whole [start, end] window:
// the in-bed reader wants the union envelope, not a placed segment. AWAKE is
// laid as a leading segment from onset so the asleep stages still partition
// 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),
// 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: mapping.fieldTag,
sleepStage: mapping.stage,
fieldTag: "sleep_in_bed",
sleepStage: "IN_BED",
});
}
+15 -4
View File
@@ -4,8 +4,14 @@
* `mapSleep` (per-stage SLEEP_DURATION rows, SLEEP_NEED, the SLEEP_*
* percentages, RESPIRATORY_RATE), and upserts as `source = WHOOP`.
*
* Per-stage rows carry the `sleepStage` axis so the five stage rows for one
* night stay distinct under the dedup key. externalId = `<sleep_id>:<fieldTag>`.
* Sleep rows carry the `sleepStage` axis so the night's rows stay distinct
* under the dedup key. WHOOP exposes only per-stage DURATION totals (no onset
* timestamps), so `mapSleep` RECONSTRUCTS an ordered, contiguous per-segment
* timeline (CORE/DEEP/REM/AWAKE laid back-to-back from sleep onset) and flags
* those rows `reconstructed`; each reconstructed segment carries its own
* indexed externalId `<sleep_id>:seg:<tag>:<i>`. IN_BED stays a single
* envelope row keyed `<sleep_id>:sleep_in_bed`. The non-segment scores keep
* the `<sleep_id>:<fieldTag>` shape.
*/
import { fetchSleeps, fetchSleepById, mapSleep } from "./client";
import {
@@ -53,7 +59,10 @@ export async function syncUserSleep(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${s.id}:${m.fieldTag}`,
// Reconstructed sleep segments carry their own indexed externalId so
// the several rows of one stage stay distinct; everything else keeps
// the `<sleep_id>:<fieldTag>` shape.
externalId: m.externalId ?? `${s.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
@@ -92,7 +101,9 @@ export async function syncWhoopSleepById(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${record.id}:${m.fieldTag}`,
// Reconstructed sleep segments carry their own indexed externalId
// (see syncUserSleep).
externalId: m.externalId ?? `${record.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}