From bc12633569f322f7bfd9830955f7bc42750bef90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:16:09 +0200 Subject: [PATCH] fix(sleep): reconstruct an ordered WHOOP hypnogram timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 12 +++ src/app/api/sleep/night/route.ts | 9 ++ .../analytics/__tests__/sleep-night.test.ts | 42 +++++++++ src/lib/analytics/sleep-night.ts | 29 ++++++ src/lib/openapi/routes/measurements.ts | 5 + src/lib/whoop/__tests__/client.test.ts | 67 +++++++++++-- src/lib/whoop/client.ts | 93 +++++++++++++++---- src/lib/whoop/sync-sleep.ts | 19 +++- 8 files changed, 246 insertions(+), 30 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bf628854e..e1fd8c8cc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index a930d3a3d..29d8874f0 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -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( diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 17b32973c..00221cea0 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -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 diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 590361df7..88024e6c1 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -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 = new Set([ + "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()); diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 15de359a7..9dbb92663 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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), }); diff --git a/src/lib/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts index 74d3f12bb..385ce9008 100644 --- a/src/lib/whoop/__tests__/client.test.ts +++ b/src/lib/whoop/__tests__/client.test.ts @@ -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 = {}; + 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", () => { diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index a75ad1e06..19c30ddc7 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -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 `:`. 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; + 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", }); } diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 234052ad0..a97173178 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -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 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 `:seg::`. IN_BED stays a single + * envelope row keyed `:sleep_in_bed`. The non-segment scores keep + * the `:` 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 `:` 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, }); }