diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 38b54aeeb..9edb6b4e8 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -526,7 +526,7 @@ describe("sleep-stage mapping", () => { expect(mapFitbitSleepStage(42)).toBeNull(); }); - it("maps a session into per-stage SLEEP_DURATION rows with measuredAt = stage END", () => { + it("maps a session into per-SEGMENT SLEEP_DURATION rows with measuredAt = segment END (real timeline)", () => { const session = { sleep: { startTime: "2026-05-10T22:00:00.000Z", @@ -556,23 +556,31 @@ describe("sleep-stage mapping", () => { }, }; const out = mapSleepSession(session); - const byStage = Object.fromEntries(out.map((m) => [m.sleepStage, m])); - // CORE = the two light segments summed (30 + 45 = 75 min). - expect(byStage.CORE!.value).toBe(75); - expect(byStage.CORE!.type).toBe("SLEEP_DURATION"); - expect(byStage.CORE!.unit).toBe("minutes"); - // measuredAt is the LATEST end for that stage (the second light segment). - expect(byStage.CORE!.measuredAt.toISOString()).toBe( + // One row PER segment (no collapse) — 4 segments → 4 rows. + expect(out).toHaveLength(4); + expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true); + expect(out.every((m) => m.unit === "minutes")).toBe(true); + + // Each row carries its OWN end instant + duration — a real clock-time + // timeline, not a stage collapse. + const core = out.filter((m) => m.sleepStage === "CORE"); + expect(core.map((m) => m.value)).toEqual([30, 45]); + expect(core.map((m) => m.measuredAt.toISOString())).toEqual([ + "2026-05-10T22:30:00.000Z", "2026-05-11T00:15:00.000Z", - ); - expect(byStage.DEEP!.value).toBe(60); - expect(byStage.REM!.value).toBe(45); + ]); + const deep = out.find((m) => m.sleepStage === "DEEP")!; + expect(deep.value).toBe(60); + expect(deep.measuredAt.toISOString()).toBe("2026-05-10T23:30:00.000Z"); + const rem = out.find((m) => m.sleepStage === "REM")!; + expect(rem.value).toBe(45); - // externalId field-tag is session-anchored so a re-score overwrites in place. - expect(byStage.DEEP!.fieldTag).toBe( - "2026-05-11T06:00:00.000Z:sleep_deep", - ); + // Every fieldTag is distinct (session anchor + stage + segment index) so + // the several segments of one stage never collide under the dedup key. + const tags = out.map((m) => m.fieldTag); + expect(new Set(tags).size).toBe(tags.length); + expect(tags).toContain("2026-05-11T06:00:00.000Z:sleep_deep:1"); }); it("anchors the session externalId on sleep.interval.end_time for an INTERVAL-shaped point", () => { @@ -592,7 +600,7 @@ describe("sleep-stage mapping", () => { }, }); const deep = out.find((m) => m.sleepStage === "DEEP"); - expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep"); + expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep:0"); }); it("yields nothing for a session with no parseable segments", () => { diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 25c217140..e31ee2450 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -1244,10 +1244,18 @@ function sleepSessionAnchor(point: FitbitDataPoint): string { } /** - * Map one Google sleep session into per-stage `SLEEP_DURATION` rows. Each - * stage's segments are summed into one row carrying the stage's `SleepStage`, - * `measuredAt = the latest segment END for that stage`, and a stage-scoped - * fieldTag so the (up to six) stage rows for one night stay distinct under the + * Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows. The + * Google sleep payload carries a real per-stage segment series (each with its + * own start/end), so one row is emitted PER SEGMENT — `measuredAt = that + * segment's END` — rather than collapsing a stage's segments onto a single + * lastEnd instant. The collapsed shape stamped every stage total on its last + * end, so the hypnogram reconstructed all of a stage's time as one block ending + * at the night's right edge (the stacked-bar artefact); per-segment rows lay + * each block at its true clock time. The timeline is MEASURED (real onsets), so + * these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets. + * + * Each segment carries a stage-scoped, INDEXED fieldTag so the several segments + * of one stage stay distinct under the `(userId, type, source, externalId)` * dedup key. Unknown stage labels are skipped; a session with no parseable * segment yields nothing. */ @@ -1257,38 +1265,24 @@ export function mapSleepSession( const segments = readSleepSegments(point); if (segments.length === 0) return []; - // Accumulate per-stage minutes + track the latest end instant for each stage. - const perStage = new Map< - FitbitSleepStage, - { minutes: number; lastEnd: Date } - >(); + const anchor = sleepSessionAnchor(point); + const out: FitbitMappedMeasurement[] = []; + let segIndex = 0; for (const seg of segments) { const stage = mapFitbitSleepStage(seg.stage); if (!stage) continue; const mins = minutesBetween(seg.startTime, seg.endTime); - if (mins === null) continue; + if (mins === null || !(mins > 0)) continue; const end = new Date(seg.endTime as string); - const prev = perStage.get(stage); - if (prev) { - prev.minutes += mins; - if (end > prev.lastEnd) prev.lastEnd = end; - } else { - perStage.set(stage, { minutes: mins, lastEnd: end }); - } - } - - const anchor = sleepSessionAnchor(point); - const out: FitbitMappedMeasurement[] = []; - for (const [stage, agg] of perStage) { - if (!(agg.minutes > 0)) continue; out.push({ type: "SLEEP_DURATION", - value: round2(agg.minutes), + value: round2(mins), unit: "minutes", - measuredAt: agg.lastEnd, - fieldTag: `${anchor}:sleep_${stage.toLowerCase()}`, + measuredAt: end, + fieldTag: `${anchor}:sleep_${stage.toLowerCase()}:${segIndex}`, sleepStage: stage, }); + segIndex += 1; } return out; } diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts index 953cd40f9..d7ac50f1f 100644 --- a/src/lib/fitbit/sync-sleep.ts +++ b/src/lib/fitbit/sync-sleep.ts @@ -2,16 +2,19 @@ * Fitbit / Google Health sleep-bundle sync (v1.12.0, W5). * * Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps - * each into per-stage `SLEEP_DURATION` rows via `mapSleepSession` (minutes per - * stage; `measuredAt = the stage's END instant; harmonised onto the shared - * `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the night-total + - * hypnogram readers already consume for WHOOP / Apple). Upserts as + * each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row + * per stage segment; `measuredAt = that segment's END instant; harmonised onto + * the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the + * night-total + hypnogram readers already consume for WHOOP / Apple). Google + * carries a real per-segment series, so the rows lay each block at its true + * clock time (a MEASURED timeline, not reconstructed). Upserts as * `source = FITBIT`. * - * Per-stage rows carry the `sleepStage` axis so the (up to six) stage rows for - * one night stay distinct under the `(userId, type, source, externalId)` dedup - * key. externalId = `:sleep_` — a re-scored night - * overwrites in place. A 24 h overlap covers Google's after-the-fact re-score. + * Each segment carries the `sleepStage` axis and an indexed fieldTag so the + * several segments of one stage stay distinct under the + * `(userId, type, source, externalId)` dedup key. externalId = + * `:sleep_:` — a re-scored night overwrites in place. + * A 24 h overlap covers Google's after-the-fact re-score. * * A per-data-class 403 soft-skips the resource — the sleep bundle is granted * independently of activity / metrics in the Google consent flow.