Files
HealthLog/src/lib/tz/local-day.ts
T
Marc-André Bombeck 91c4f789db 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.
2026-07-19 02:05:24 +02:00

149 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Local-calendar-day helpers built on `wallClockInTz`.
*
* Merged in from the former `src/lib/timezone.ts` so every
* Intl-backed timezone primitive lives under `src/lib/tz/`. The
* wall-clock decomposition itself is `wallClockInTz` (see
* `./wall-clock`); the helpers here derive UTC instants from the
* 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, zonedWallClockToUtc } from "./wall-clock";
/**
* Get the start (midnight) and end (23:59:59.999) of "today" in the user's
* timezone, returned as UTC Date objects suitable for database queries.
*
* DST-safe. Both bounds route through {@link startOfLocalDayInTz}, whose
* two-pass solver settles the zone offset AT the target local midnight —
* not at `now`. The window therefore spans the user's real local day even
* when it is 23 h (spring-forward) or 25 h (fall-back): the previous
* implementation sampled the offset at `now` and hardcoded a 24 h length,
* so on a fall-back day the last local hour (e.g. a 23:30 dose) fell
* OUTSIDE `[midnight, midnight+24h)` and on a spring-forward day the window
* bled one hour into the next local day.
*/
export function getUserTodayBounds(
now: Date,
tz: string,
): { start: Date; end: Date } {
const start = startOfLocalDayInTz(now, tz);
// Start of the NEXT local day. Advancing 25 h always crosses into the next
// calendar day (a real day is 23–25 h, never ≤ 12.5 h, so 25 h can neither
// fall short of the next day nor skip past it), then floor back to that
// day's local midnight. `end` is the inclusive last millisecond of today.
const nextDayStart = startOfLocalDayInTz(
new Date(start.getTime() + 25 * 60 * 60 * 1000),
tz,
);
const end = new Date(nextDayStart.getTime() - 1);
return { start, end };
}
/**
* UTC instant of local midnight (00:00 wall-clock) on the calendar day
* `instant` falls on in `tz`. DST-safe: derives the zone offset at the
* target local time so the returned instant always represents the user's
* local start-of-day, never UTC midnight of that calendar date.
*
* Canonical start-of-local-day primitive. The medication-scheduling
* `startOfLocalDay` (cadence) and `startOfDayInTz` (recurrence) route
* through this so the day-floor never drifts between surfaces. When `tz`
* is omitted the host's system-local day is used.
*/
export function startOfLocalDayInTz(
instant: Date,
tz: string | undefined,
): Date {
if (!tz) {
return new Date(
instant.getFullYear(),
instant.getMonth(),
instant.getDate(),
0,
0,
0,
0,
);
}
const parts = wallClockInTz(instant, tz);
// Two-pass converge: treat the wall clock as UTC, then correct by the
// zone offset at that approximate instant. The second pass settles the
// offset across a DST transition for every IANA zone.
let guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0),
);
for (let i = 0; i < 2; i++) {
const at = wallClockInTz(guess, tz);
const asIfUtc = Date.UTC(
at.year,
at.month - 1,
at.day,
at.hour,
at.minute,
at.second,
);
const offsetMin = Math.round((asIfUtc - guess.getTime()) / 60_000);
guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0) -
offsetMin * 60_000,
);
}
return guess;
}
/**
* Get the day of the week (0=Sun..6=Sat) for a given instant in a specific
* timezone.
*/
export function getDayOfWeekInTz(now: Date, tz: string): number {
return wallClockInTz(now, tz).weekday;
}
/**
* Compute the UTC instant of local `hh:mm` on the local calendar day
* 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,
tz: string,
hour: number,
minute: number,
): Date {
const parts = wallClockInTz(now, tz);
return zonedWallClockToUtc(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour,
minute,
second: 0,
},
tz,
);
}