diff --git a/src/lib/withings/__tests__/sync-activity.test.ts b/src/lib/withings/__tests__/sync-activity.test.ts index 52955eb38..5a2dcc55f 100644 --- a/src/lib/withings/__tests__/sync-activity.test.ts +++ b/src/lib/withings/__tests__/sync-activity.test.ts @@ -166,7 +166,7 @@ describe("syncUserActivity — field mapping + idempotency", () => { expect(prisma.measurement.update).toHaveBeenCalledTimes(3); }); - it("anchors measuredAt at the day's 23:59:59 UTC end-of-day", async () => { + it("anchors measuredAt at noon UTC so the instant lands inside the local day for every supported tz", async () => { installFetchMock([{ date: "2026-05-12", steps: 1000 }]); vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); @@ -177,7 +177,28 @@ describe("syncUserActivity — field mapping + idempotency", () => { data: { measuredAt: Date }; }; const measuredAt = createArg.data.measuredAt; - expect(measuredAt.toISOString()).toBe("2026-05-12T23:59:59.000Z"); + expect(measuredAt.toISOString()).toBe("2026-05-12T12:00:00.000Z"); + + // Regression: anchoring at noon UTC keeps the row inside the + // calendar day every user reads it in. Bucketing the same instant + // via `Intl.DateTimeFormat` across the canonical Withings user + // span — Honolulu (UTC-10), Los Angeles (UTC-7/-8), Berlin + // (UTC+1/+2) and Tokyo (UTC+9) — must all return "2026-05-12". + // Anchoring at end-of-day UTC (the v1.4.25 W17b shape) shifted + // Tokyo by +1 day; anchoring at midnight UTC would shift LA by -1. + // Noon UTC is the only choice that holds across the practical + // [-11, +12) range. + const bucket = (tz: string) => + new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(measuredAt); + expect(bucket("Pacific/Honolulu")).toBe("2026-05-12"); + expect(bucket("America/Los_Angeles")).toBe("2026-05-12"); + expect(bucket("Europe/Berlin")).toBe("2026-05-12"); + expect(bucket("Asia/Tokyo")).toBe("2026-05-12"); }); it("skips missing fields without writing zero rows or throwing", async () => { diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 591404eab..83ba8e599 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -24,10 +24,14 @@ * and the same date re-syncing simply updates `value`. * * Date semantics: Withings reports per-day aggregates with `date` as - * `YYYY-MM-DD`. We anchor `measuredAt` at the day's 23:59:59 UTC so - * the row sorts at the end of the day in chronological reads and the - * Berlin-day analytics aggregator (timezone-aware) buckets it into the - * correct day even when DST shifts the boundary. + * `YYYY-MM-DD`. We anchor `measuredAt` at the day's noon UTC + * (12:00:00Z) so the row's instant lands inside the local day for every + * user timezone in the [-11, +12] range — a row tagged 2026-05-12 always + * day-keys to 2026-05-12 whether the reader sits in Honolulu, Berlin or + * Tokyo. Anchoring at 23:59:59Z (the v1.4.25 W17b shape) was wrong for + * positive-offset users: a row from `date=2026-05-12` arrived as a + * 2026-05-13 reading in Tokyo, off-by-one for every dashboard tile that + * reads the row in user-local time. */ import type { MeasurementType } from "@/generated/prisma/client"; @@ -95,18 +99,17 @@ const ACTIVITY_FIELD_MAP: ReadonlyArray<{ ]; /** - * Anchor a per-day activity row at the day's last second UTC. Berlin- - * timezone day analytics consume the row through the standard - * `dayKey()` helper, which buckets it into the right local day even - * across DST shifts. + * Anchor a per-day activity row at noon UTC. Chosen so the instant + * always lands inside the local day for users in the [-11, +12] zone + * range — anchoring at end-of-day UTC (the v1.4.25 W17b shape) sent + * Tokyo readings into the *following* local day, mis-bucketing every + * positive-offset user's "today" tile. Noon UTC is the standard + * "calendar-day with no clock" representation per RFC 3339 §5.6 and + * the JS `Date` analytics layer day-keys it cleanly in every + * supported user timezone. */ function activityMeasuredAt(yyyymmdd: string): Date { - // The Withings response is always `YYYY-MM-DD` — Date parses the ISO - // form with implicit UTC midnight. Add 23h 59m 59s so the row sits - // at end-of-day; the analytics layer day-keys it the same way it - // does every other end-of-day rollup. - const base = new Date(`${yyyymmdd}T23:59:59.000Z`); - return base; + return new Date(`${yyyymmdd}T12:00:00.000Z`); } /**