fix(withings): anchor activity sync at noon UTC for tz-safe bucketing

Withings reports per-day aggregates with `date` as `YYYY-MM-DD`. The
v1.4.25 W17b writer anchored `measuredAt` at the day's 23:59:59 UTC so
the row would sort at the end of the day in chronological reads. That
shape mis-bucketed positive-offset users: a row from `date=2026-05-12`
arrived as 2026-05-13 08:59:59 in Tokyo, off-by-one for every
dashboard tile that reads the row in user-local time.

Anchor at noon UTC (`12:00:00Z`) instead — the standard "calendar-day
with no clock" representation per RFC 3339 §5.6. The instant lands
inside the local calendar day for every supported user timezone in
the practical [-11, +12) range (Honolulu through Tokyo), so the
analytics layer day-keys the row to the same calendar day every user
reads it in.

Regression test covers the four canonical user zones; the existing
end-of-day assertion is updated.

Fix-O / senior-M2.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:38:01 +02:00
1 parent 4c4fa645b1
commit 701fd5f251
2 files changed
+40 -16

No files matched your search

@@ -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 () => {
+17 -14
View File
@@ -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`);
}
/**