From 290f46fcda321bc2a446adfd26251eb8f81a6b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:19:25 +0200 Subject: [PATCH] fix(sleep): label Polar nights reconstructed and emit their awake time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../analytics/__tests__/sleep-night.test.ts | 16 +++ src/lib/analytics/sleep-night.ts | 10 +- src/lib/polar/__tests__/client.test.ts | 47 +++++++ src/lib/polar/client.ts | 69 +++++----- .../__tests__/reconstruct-timeline.test.ts | 90 ++++++++++++ src/lib/sleep/reconstruct-timeline.ts | 129 ++++++++++++++++++ src/lib/whoop/client.ts | 76 ++++------- 7 files changed, 351 insertions(+), 86 deletions(-) create mode 100644 src/lib/sleep/__tests__/reconstruct-timeline.test.ts create mode 100644 src/lib/sleep/reconstruct-timeline.ts diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 00221cea0..b10ce131c 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -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"), diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 88024e6c1..138d94e20 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -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 = new Set([ "WHOOP", + "POLAR", ]); /** diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index d68ae79f6..81ad2ff96 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -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", diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 1de24b073..7a8395d25 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -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, 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({ diff --git a/src/lib/sleep/__tests__/reconstruct-timeline.test.ts b/src/lib/sleep/__tests__/reconstruct-timeline.test.ts new file mode 100644 index 000000000..121eb22a6 --- /dev/null +++ b/src/lib/sleep/__tests__/reconstruct-timeline.test.ts @@ -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(); + }); +}); diff --git a/src/lib/sleep/reconstruct-timeline.ts b/src/lib/sleep/reconstruct-timeline.ts new file mode 100644 index 000000000..cc0bfa335 --- /dev/null +++ b/src/lib/sleep/reconstruct-timeline.ts @@ -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; + /** 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; +} diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 19c30ddc7..84d0a01ad 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -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; - 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 =