mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user