Files
HealthLog/src/lib/tz/wall-clock.ts
T
Marc-André Bombeck eb071b5dd8 fix(medications): drop the fabricated-Date wall clock from the window status
toZonedDate rendered an instant as an en-US string in the target zone and
re-parsed it as host-local. Two defects rode on that.

When the fabricated wall clock landed in the HOST zone's spring-forward gap
the engine silently advanced it an hour, so a dose inside its window could
be classified late and a just-taken dose could fail to suppress the take-now
pill — for users whose device zone differs from their profile zone.

The medication card and table then passed that already-shifted value into
dateWithWeekdaySmart, which applies the profile zone itself, so the next-
intake day label converted twice and could name the wrong calendar day.
Invisible whenever browser zone and profile zone agree.

Migrate every consumer to wallClockInTz, which returns the fields directly.
The status math only ever reads year/month/day/hour/minute/weekday, so it
now runs on WallClockParts throughout and reduceCurrentWindowStatus takes
the real instant instead of a pre-shifted Date — caller and helper can no
longer disagree about which zone the comparison runs in. Day labels use a
calendar-day distance (localDayIndex) rather than an hour count, so a 23 h
or 25 h day cannot round tomorrow into today, and the formatter receives
the raw instant.

Fixtures name the zone they mean instead of building host-local Dates.
2026-07-19 02:13:08 +02:00

186 lines
5.3 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.
/**
* Wall-clock-in-timezone helper shared by the medication scheduling
* + compliance-rollup paths.
*
* Decomposes a `Date` instant into the year/month/day/hour/minute/
* second/weekday tuple an observer in `tz` would read off a wall
* clock at that moment. Honours DST because `Intl.DateTimeFormat`
* does.
*
* When `tz` is omitted the helper falls back to the host's system-
* local representation so the v1.4.25 W19e callers that pre-date the
* per-user timezone work keep their original shape.
*
* v1.4.40 W-GHOSTS consolidated two file-local copies
* (`compliance-rollups.ts` returning `{year,month,day}` only +
* `scheduling/cadence.ts` returning the full tuple) into this single
* canonical export. The cross-tz ±3 h guard hinges on these helpers
* staying in lock-step; keeping them as separate definitions made
* silent drift a real failure mode.
*/
import { getDateTimeFormat } from "./intl-cache";
export interface WallClockParts {
year: number;
/** 1–12 (January = 1, December = 12). */
month: number;
/** 1–31. */
day: number;
/** 0–23 (24 normalised to 0 to match Date.getHours() semantics). */
hour: number;
minute: number;
second: number;
/** 0 = Sunday … 6 = Saturday. */
weekday: number;
}
const WEEKDAY_MAP: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
/**
* Hoisted so the formatter memo's WeakMap signature lookup hits the same
* object identity on every call — a warm `wallClockInTz` no longer pays
* `Intl.DateTimeFormat` construction (the dominant cost of the band
* expansion + rollup paths that call this in a tight loop). Frozen: the
* memo serialises the signature ONCE per object identity, so a later
* mutation would leave the cached signature stale.
*/
const WALL_CLOCK_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
Object.freeze({
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
});
/**
* The wall-clock fields a `zonedWallClockToUtc` caller supplies. Months are
* 1-based to match `WallClockParts`; second defaults to 0 when omitted.
*/
export interface WallClockInput {
year: number;
/** 1–12 (January = 1, December = 12). */
month: number;
/** 1–31. */
day: number;
/** 0–23. */
hour: number;
minute: number;
second?: number;
}
/**
* Inverse of `wallClockInTz`: given the wall-clock an observer in `tz` reads
* (e.g. an offset-less `2020-01-26T03:02:30` Fitbit sleep timestamp), return the
* UTC instant it denotes. DST-safe via the same two-pass converge as
* `startOfLocalDayInTz` — the second pass settles the zone offset across a DST
* transition for every IANA zone. When `tz` is omitted the parts are read as the
* host's system-local clock so offset-less callers keep their legacy shape.
*/
export function zonedWallClockToUtc(
parts: WallClockInput,
tz: string | undefined,
): Date {
const second = parts.second ?? 0;
if (!tz) {
return new Date(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
second,
0,
);
}
// Treat the wall clock as if it were UTC, then correct by the zone offset at
// that approximate instant; one extra pass settles the DST boundary.
const asIfUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
second,
0,
);
let guess = new Date(asIfUtc);
for (let i = 0; i < 2; i++) {
const at = wallClockInTz(guess, tz);
const guessAsIfUtc = Date.UTC(
at.year,
at.month - 1,
at.day,
at.hour,
at.minute,
at.second,
0,
);
const offsetMin = Math.round((guessAsIfUtc - guess.getTime()) / 60_000);
guess = new Date(asIfUtc - offsetMin * 60_000);
}
return guess;
}
/**
* The local calendar day as a day index, for day-distance arithmetic
* between two wall clocks in the SAME zone.
*
* Built from the wall-clock fields through `Date.UTC`, so the difference
* of two indices is a pure calendar-day count: no host offset and no
* 23 h/25 h DST-length term can enter it. Subtracting two instants and
* dividing by 86 400 000 does admit both, which is how a "tomorrow" label
* could land on a transition day.
*/
export function localDayIndex(parts: WallClockParts): number {
return Math.floor(
Date.UTC(parts.year, parts.month - 1, parts.day) / 86_400_000,
);
}
export function wallClockInTz(
date: Date,
tz: string | undefined,
): WallClockParts {
if (!tz) {
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
weekday: date.getDay(),
};
}
const parts = getDateTimeFormat(
"en-US",
tz,
WALL_CLOCK_OPTIONS,
).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour,
minute: Number(get("minute")),
second: Number(get("second")),
weekday: WEEKDAY_MAP[get("weekday")] ?? 0,
};
}