From 7ad0b9c4f434d296a79f0dd3f50bd73062175e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:16:23 +0200 Subject: [PATCH] fix(sleep): stamp Withings sleep segments at the END, not the START MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withings sleep sync stamped each segment's measuredAt with its startdate, but every reader treats measuredAt as the segment END and resolves the span as start = end − duration. Each night was therefore rendered one segment-length earlier than reality. Stamp the enddate so the span lands at its true clock time. --- src/lib/withings/__tests__/sync-sleep.test.ts | 7 +++++-- src/lib/withings/sync-sleep.ts | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index 28757ebd0..6ae04f3a1 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -194,7 +194,7 @@ describe("syncUserSleep — segment writes + idempotency", () => { expect(firstDuration.data.unit).toBe("minutes"); }); - it("converts startdate (unix seconds) to a Date in measuredAt", async () => { + it("stamps measuredAt at the segment END (enddate, unix seconds → Date)", async () => { installFetchMock([ { startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 }, ]); @@ -205,7 +205,10 @@ describe("syncUserSleep — segment writes + idempotency", () => { const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as { data: { measuredAt: Date }; }; - expect(arg.data.measuredAt.getTime()).toBe(1715000000 * 1000); + // measuredAt is the segment END — every reader treats it as the END and + // resolves the span as start = end − duration. Stamping the START shifted + // the night one segment-length earlier. + expect(arg.data.measuredAt.getTime()).toBe(1715003600 * 1000); }); it("skips state 4 (synthetic marker) without throwing", async () => { diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 231d94f6b..4b92080d2 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -26,10 +26,13 @@ * when Withings re-emits the night with adjusted segment boundaries. * * Date semantics: each segment's `measuredAt` is the segment's - * `startdate` (unix seconds → `Date`). The duration in minutes lands - * in `value`. The HealthLog analytics aggregator already groups - * stage rows under their parent night via the per-night `dayKey` - * helper, so the segment-level timestamp is the canonical sort key. + * `enddate` (unix seconds → `Date`). Every reader treats `measuredAt` + * as the segment END and resolves the span as `start = end − duration` + * (`sleep-night.ts` `segmentOf`); stamping the START shifted the whole + * night one segment-length earlier. The duration in minutes lands in + * `value`. The HealthLog analytics aggregator groups stage rows under + * their parent night via the per-night `dayKey` helper, so the + * segment-level END timestamp is the canonical sort key. */ import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; @@ -258,7 +261,11 @@ export async function syncUserSleep( segmentIndex++; continue; } - const measuredAt = new Date(segment.startdate * 1000); + // `measuredAt` is the segment END (`enddate`). Every reader treats + // `measuredAt` as the END instant — `segmentOf` resolves the span as + // `start = measuredAt − value·60_000` (sleep-night.ts). Stamping the START + // here shifted the whole night one segment-length earlier; END is correct. + const measuredAt = new Date(segment.enddate * 1000); const durationSec = Math.max(0, segment.enddate - segment.startdate); // Canonical SLEEP_DURATION unit is minutes (Apple Health alignment, // v1.4.23 schema note). Round so re-sync doesn't flip values on