fix(sleep): stamp Withings sleep segments at the END, not the START

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:16:23 +02:00
1 parent bd467d0c4d
commit 7ad0b9c4f4
2 files changed
+17 -7

No files matched your search

@@ -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 () => {
+12 -5
View File
@@ -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