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