fix(sleep): emit Fitbit sleep as per-segment rows, not a stage collapse

The Google sleep payload carries a real per-stage segment series, but the
mapper summed each stage's segments into one row stamped on the stage's
last end. The hypnogram then reconstructed all of a stage's time as a
single block ending at the night's right edge — the same stacked-bar
artefact as WHOOP, despite the source carrying true timing.

Emit one row per segment with measuredAt at that segment's end and an
indexed externalId so the several segments of one stage stay distinct
under the dedup key. The timeline is measured (real onsets), so these
rows are not flagged reconstructed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:16:16 +02:00
1 parent bc12633569
commit bd467d0c4d
3 files changed
+55 -50

No files matched your search

+24 -16
View File
@@ -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", () => {
+20 -26
View File
@@ -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;
}
+11 -8
View File
@@ -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 = `<session-anchor>:sleep_<stage>` — 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 =
* `<session-anchor>:sleep_<stage>:<i>` — 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.