mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(tz): settle localHmAsUtc's offset at the target local time
localHmAsUtc sampled the zone offset at the reference instant and applied it to the target wall clock, so on a DST-transition day the answer depended on which side of the transition the reference sat: a 20:00 slot resolved to 19:00 local when the caller ticked before the Berlin autumn change and correctly when it ticked after. That broke the slot-dedupe contract. The projector, the reminder worker and the intake write path each pass their own reference instant for the same slot and key on the resulting instant byte-for-byte, so on that day they disagreed and a duplicate pending row became reachable. Rebuild it on zonedWallClockToUtc: resolve the local calendar day from the reference, then convert that day's hh:mm. The offset now settles at the target local time, and because zonedWallClockToUtc runs the same two-pass converge as startOfLocalDayInTz, localHmAsUtc(x, tz, 0, 0) equals startOfLocalDayInTz(x, tz) by construction — the two used to disagree by an hour on transition days, which opened the one-shot on-time band an hour into the previous local day. Cover both Berlin transitions from references on either side, plus a southern-hemisphere zone whose transitions invert.
This commit is contained in:
1 parent
24b1269b28
commit
91c4f789db
2 files changed
+130
-22
No files matched your search
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import {
|
||||
getUserTodayBounds,
|
||||
localHmAsUtc,
|
||||
startOfLocalDayInTz,
|
||||
} from "@/lib/tz/local-day";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
@@ -151,3 +155,94 @@ describe("getUserTodayBounds — DST-safe local-day window", () => {
|
||||
expect(wallClockInTz(start, "America/Los_Angeles").day).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localHmAsUtc — DST transition days", () => {
|
||||
const TZ = "Europe/Berlin";
|
||||
// Berlin 2026 transitions: spring forward Mar 29 (02:00 CET → 03:00 CEST),
|
||||
// autumn back Oct 25 (03:00 CEST → 02:00 CET).
|
||||
|
||||
/** The wall clock an observer in `tz` reads at `instant`, as "HH:mm". */
|
||||
function localHm(instant: Date, tz: string): string {
|
||||
const p = wallClockInTz(instant, tz);
|
||||
return `${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
it("resolves a 20:00 slot to 20:00 local from a PRE-transition reference (autumn back)", () => {
|
||||
// 02:30 CEST — before the 03:00 fall-back. The old implementation
|
||||
// sampled the +02:00 offset here and applied it to the 20:00 target,
|
||||
// which by then sits at +01:00, yielding 19:00 local.
|
||||
const ref = new Date("2026-10-25T00:30:00.000Z");
|
||||
const slot = localHmAsUtc(ref, TZ, 20, 0);
|
||||
expect(localHm(slot, TZ)).toBe("20:00");
|
||||
expect(slot.toISOString()).toBe("2026-10-25T19:00:00.000Z");
|
||||
});
|
||||
|
||||
it("resolves a 20:00 slot to 20:00 local from a POST-transition reference (autumn back)", () => {
|
||||
const ref = new Date("2026-10-25T05:00:00.000Z"); // 06:00 CET
|
||||
const slot = localHmAsUtc(ref, TZ, 20, 0);
|
||||
expect(localHm(slot, TZ)).toBe("20:00");
|
||||
expect(slot.toISOString()).toBe("2026-10-25T19:00:00.000Z");
|
||||
});
|
||||
|
||||
it("keys the SAME instant for one slot regardless of reference side (autumn back)", () => {
|
||||
// The dedupe contract: projector, reminder worker and intake write path
|
||||
// each pass their own reference instant for the same slot. They must
|
||||
// agree byte-for-byte or a duplicate pending row becomes reachable.
|
||||
const pre = new Date("2026-10-25T00:30:00.000Z");
|
||||
const mid = new Date("2026-10-25T01:30:00.000Z");
|
||||
const post = new Date("2026-10-25T05:00:00.000Z");
|
||||
const slots = [pre, mid, post].map((r) =>
|
||||
localHmAsUtc(r, TZ, 20, 0).getTime(),
|
||||
);
|
||||
expect(new Set(slots).size).toBe(1);
|
||||
});
|
||||
|
||||
it("keys the SAME instant for one slot regardless of reference side (spring forward)", () => {
|
||||
const pre = new Date("2026-03-29T00:30:00.000Z"); // 01:30 CET
|
||||
const post = new Date("2026-03-29T09:00:00.000Z"); // 11:00 CEST
|
||||
expect(localHmAsUtc(pre, TZ, 20, 0).getTime()).toBe(
|
||||
localHmAsUtc(post, TZ, 20, 0).getTime(),
|
||||
);
|
||||
expect(localHm(localHmAsUtc(pre, TZ, 20, 0), TZ)).toBe("20:00");
|
||||
});
|
||||
|
||||
it("agrees with startOfLocalDayInTz at 00:00 on BOTH transition days", () => {
|
||||
// By construction now — both route through the same two-pass converge.
|
||||
// They used to disagree by an hour here, which opened the one-shot
|
||||
// on-time band an hour into the previous local day.
|
||||
for (const iso of [
|
||||
"2026-03-29T00:30:00.000Z", // spring forward, pre
|
||||
"2026-03-29T09:00:00.000Z", // spring forward, post
|
||||
"2026-10-25T00:30:00.000Z", // autumn back, pre
|
||||
"2026-10-25T05:00:00.000Z", // autumn back, post
|
||||
]) {
|
||||
const ref = new Date(iso);
|
||||
expect(localHmAsUtc(ref, TZ, 0, 0).getTime()).toBe(
|
||||
startOfLocalDayInTz(ref, TZ).getTime(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves an early-morning slot on the spring-forward day", () => {
|
||||
// 01:30 local exists (CET, +01:00) on the spring-forward day; 03:30
|
||||
// exists (CEST, +02:00). Both must round-trip to their own wall clock.
|
||||
const ref = new Date("2026-03-29T12:00:00.000Z");
|
||||
expect(localHmAsUtc(ref, TZ, 1, 30).toISOString()).toBe(
|
||||
"2026-03-29T00:30:00.000Z",
|
||||
);
|
||||
expect(localHmAsUtc(ref, TZ, 3, 30).toISOString()).toBe(
|
||||
"2026-03-29T01:30:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves slots in a southern-hemisphere zone whose transitions invert", () => {
|
||||
// Australia/Sydney falls back 2026-04-05 03:00 AEDT → 02:00 AEST.
|
||||
const sydney = "Australia/Sydney";
|
||||
const pre = new Date("2026-04-04T14:30:00.000Z"); // 01:30 AEDT (+11), Apr 5
|
||||
const post = new Date("2026-04-05T09:00:00.000Z"); // 19:00 AEST (+10), Apr 5
|
||||
const a = localHmAsUtc(pre, sydney, 20, 0);
|
||||
const b = localHmAsUtc(post, sydney, 20, 0);
|
||||
expect(a.getTime()).toBe(b.getTime());
|
||||
expect(localHm(a, sydney)).toBe("20:00");
|
||||
});
|
||||
});
|
||||
+34
-21
@@ -8,7 +8,7 @@
|
||||
* user's local calendar day — today's bounds for database queries,
|
||||
* the local weekday, and the UTC instant of a local `hh:mm`.
|
||||
*/
|
||||
import { wallClockInTz } from "./wall-clock";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "./wall-clock";
|
||||
|
||||
/**
|
||||
* Get the start (midnight) and end (23:59:59.999) of "today" in the user's
|
||||
@@ -103,11 +103,29 @@ export function getDayOfWeekInTz(now: Date, tz: string): number {
|
||||
|
||||
/**
|
||||
* Compute the UTC instant of local `hh:mm` on the local calendar day
|
||||
* implied by `now` in `tz`. DST-safe — `todayStart.getTime() + h * 3.6e6`
|
||||
* drifts by an hour on spring-forward / fall-back days because raw UTC
|
||||
* arithmetic ignores the DST jump between local midnight and local
|
||||
* `hh:mm`. This helper re-derives the offset at the target local time
|
||||
* so the returned instant always represents local `hh:mm`.
|
||||
* implied by `now` in `tz`.
|
||||
*
|
||||
* Two steps, both zone-aware: resolve which local calendar day `now`
|
||||
* falls on via {@link wallClockInTz}, then convert that day's `hh:mm`
|
||||
* wall clock to its UTC instant via `zonedWallClockToUtc`.
|
||||
*
|
||||
* The offset is therefore settled AT THE TARGET local time, not at the
|
||||
* reference instant. The previous implementation sampled the offset at
|
||||
* `now` and applied it to the target, so on a DST-transition day the
|
||||
* answer depended on which side of the transition the reference sat:
|
||||
* a 20:00 slot resolved to 19:00 local when the caller ticked before the
|
||||
* Berlin autumn change and correctly when it ticked after. That broke
|
||||
* two contracts at once — the projector, the reminder worker and the
|
||||
* intake write path key on the byte-identical slot instant, and they
|
||||
* pass different reference instants for the same slot, so a duplicate
|
||||
* pending row became reachable on that day.
|
||||
*
|
||||
* `zonedWallClockToUtc` runs the same two-pass converge as
|
||||
* {@link startOfLocalDayInTz}, so `localHmAsUtc(x, tz, 0, 0)` now equals
|
||||
* `startOfLocalDayInTz(x, tz)` BY CONSTRUCTION rather than by
|
||||
* coincidence — the two used to disagree by an hour on transition days,
|
||||
* which opened the one-shot on-time band an hour into the previous
|
||||
* local day.
|
||||
*/
|
||||
export function localHmAsUtc(
|
||||
now: Date,
|
||||
@@ -116,20 +134,15 @@ export function localHmAsUtc(
|
||||
minute: number,
|
||||
): Date {
|
||||
const parts = wallClockInTz(now, tz);
|
||||
const localAtTargetAsUtc = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute, 0, 0),
|
||||
return zonedWallClockToUtc(
|
||||
{
|
||||
year: parts.year,
|
||||
month: parts.month,
|
||||
day: parts.day,
|
||||
hour,
|
||||
minute,
|
||||
second: 0,
|
||||
},
|
||||
tz,
|
||||
);
|
||||
const localNowAsUtc = new Date(
|
||||
Date.UTC(
|
||||
parts.year,
|
||||
parts.month - 1,
|
||||
parts.day,
|
||||
parts.hour,
|
||||
parts.minute,
|
||||
parts.second,
|
||||
),
|
||||
);
|
||||
const offsetMs =
|
||||
Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000;
|
||||
return new Date(localAtTargetAsUtc.getTime() - offsetMs);
|
||||
}
|
||||
Reference in new issue
Block a user