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:
Marc-André Bombeck committed 2026-07-19 02:05:24 +02:00
1 parent 24b1269b28
commit 91c4f789db
2 files changed
+130 -22

No files matched your search

+96 -1
View File
@@ -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
View File
@@ -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);
}