Files
HealthLog/src/lib/tz/format.ts
T
Marc-André Bombeck fb6813bcdb fix(medications): bucket the local day in the user's zone, DST-safe
Five timezone-correctness fixes across the medication / compliance surfaces:

- getUserTodayBounds now derives both bounds through the DST-safe
  startOfLocalDayInTz, so the today window is 23h on spring-forward and
  25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30
  local dose no longer drops off the med cards, dashboard, PWA badge and
  reminder idempotency near midnight on a transition day.
- dailyComplianceRatesFromLedger takes the user's zone and buckets each
  per-day rate with userDayKey, so a non-Berlin user's evening dose is
  filed on the local day it was due rather than the next Berlin day.
- classifyIntakeTiming interprets the window HH:mm in an optional zone;
  the achievements perfect-day check passes the user's zone and reads the
  scheduled minute-of-day in-zone, so a dose far from UTC is graded
  against local wall time.
- The /insights greeting resolves the hour in the user's stored zone via
  the new hourInTz helper, matching the dashboard hero.
- The doctor-report and schedule-anchored compliance queries load
  schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad
  include, so a future engine column reaches both surfaces.
2026-07-08 23:38:48 +02:00

274 lines
9.9 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.
/**
* v1.4.25 W7 — pure timezone helpers (no Prisma, no Node-only modules).
*
* Split out from `./resolver` in v1.4.25 Fix-G so client components can
* import constants and formatters without dragging `node:module` (via
* Prisma) into the browser bundle. The Prisma-backed resolvers
* (`resolveUserTimezone`, `resolveServerDefaultTimezone`) stay in
* `./resolver`; the resolver re-exports every helper from this file so
* existing server-side callers keep their `@/lib/tz/resolver` import
* path. New code should prefer `@/lib/tz/format` for the pure helpers
* and `@/lib/tz/resolver` only when it needs the cached User/Settings
* lookup.
*/
import { getDateTimeFormat } from "./intl-cache";
export const DEFAULT_TIMEZONE = "Europe/Berlin";
/**
* Hoisted option constants so the formatter memo's WeakMap signature
* lookup hits the same object identity on every call — see
* `./intl-cache`. `WALL_CLOCK_PARTS_OPTIONS` is shared by
* `wallClockParts` AND `tzOffsetMinutes`, so both resolve to the SAME
* memoised formatter per timezone. Frozen: the memo computes each
* constant's serialised signature ONCE per object identity, so a later
* mutation would leave the cached signature stale and silently hand
* callers a formatter built from the pre-mutation options.
*/
const VALIDATION_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
Object.freeze({});
const HOUR_MINUTE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
Object.freeze({
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const WALL_CLOCK_PARTS_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,
});
/**
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
* Returns `true` if the runtime accepts the zone, `false` otherwise.
* Cheap (microseconds) — call freely at write paths.
*/
export function isValidTimezone(tz: string): boolean {
if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) {
return false;
}
try {
// The memo only caches successful constructions, so probing an
// invalid zone through it cannot poison the formatter map — and a
// valid zone leaves its formatter warm for the real callers.
getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS);
return true;
} catch {
return false;
}
}
/**
* The list of every IANA zone the current Node runtime accepts.
* Returns an empty array on engines older than Node 22 / V8 12.5
* where `Intl.supportedValuesOf` is unavailable — callers should
* fall back to free-text input plus `isValidTimezone()` validation.
*/
export function listSupportedTimezones(): string[] {
const supportedValuesOf = (
Intl as unknown as {
supportedValuesOf?: (key: string) => string[];
}
).supportedValuesOf;
if (typeof supportedValuesOf !== "function") return [];
try {
return supportedValuesOf("timeZone");
} catch {
return [];
}
}
/**
* Client-only helper. Reads the browser's resolved timezone via the
* `Intl` API. Returns the string the browser hands us — no
* validation, no fallback — because the consumer (signup form,
* profile picker hint) is going to round-trip the value through the
* server's `isValidTimezone()` check anyway. Safe on every modern
* browser back to Edge 18 / Safari 11 / Chrome 24.
*/
export function detectBrowserTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return DEFAULT_TIMEZONE;
}
}
/**
* v1.4.38 W-A — cross-tz fast-path runtime guard.
*
* Returns `true` when the supplied IANA zone is within ±3 hours of UTC
* at the supplied instant. Used by the analytics fast-paths
* (`bp-in-target-fast-path`, `correlations-fast-path`) to gate the
* rollup read-swap: those helpers key rollup rows on UTC-midnight
* `bucketStart` and pair them with per-event streams keyed in the
* user's local timezone. For Berlin (+1/+2) the day-key on the rollup
* and the day-key on the per-event row line up; for Honolulu (-10)
* they slip by a calendar day. Three hours is a conservative pin —
* the worst-case slip inside the window is the wall-clock hour-of-day
* the user happens to log at, and three hours covers every European
* zone plus the western edge of Mid-Atlantic / eastern edge of the
* Americas.
*
* Falls back to "near-UTC" (i.e. returns `true`) when the zone string
* is invalid — the resolver layer is already defensive about junk
* values, and a `true` here means the caller takes the rollup
* fast-path which is the safer default for the canonical Berlin
* tenant.
*
* The `now` argument lets callers honour DST transitions; the offset
* of `Europe/Berlin` is +1 in January and +2 in July. Defaults to
* "now" so most callers can `isNearUtc(userTz)`.
*/
export function isNearUtc(tz: string, now: Date = new Date()): boolean {
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
const offsetMinutes = tzOffsetMinutes(now, safeTz);
return Math.abs(offsetMinutes) <= 3 * 60;
}
export type FormatInUserTzShape =
"iso-with-offset" | "wall-clock" | "datetime" | "date";
/**
* Format an instant in the user's timezone.
*
* - "iso-with-offset" — `2026-05-11T11:05:00+02:00` (no
* milliseconds, sortable, machine-parseable). Issue #167 fix:
* replaces `.toISOString()` in CSV/JSON exports.
*
* - "wall-clock" — `11:05` (24h, no seconds). Used by chart axis
* labels and notification preambles.
*
* - "datetime" — `2026-05-11 11:05` (24h, locale-independent for
* audit/PDF tables). Equivalent to the `formatters.dateTime`
* helper but with a per-call timezone instead of the global
* `DISPLAY_TIMEZONE`.
*
* - "date" — `2026-05-11` (ISO date in the user's zone). Used as a
* stable day-bucket key for per-user aggregation.
*/
export function formatInUserTz(
date: Date,
tz: string,
format: FormatInUserTzShape = "iso-with-offset",
): string {
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
switch (format) {
case "iso-with-offset":
return formatIsoWithOffset(date, safeTz);
case "wall-clock":
return getDateTimeFormat("en-GB", safeTz, HOUR_MINUTE_OPTIONS).format(
date,
);
case "datetime": {
const parts = wallClockParts(date, safeTz);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
}
case "date": {
const parts = wallClockParts(date, safeTz);
return `${parts.year}-${parts.month}-${parts.day}`;
}
}
}
/**
* Day-bucket key in the user's timezone. Used as a stable Map key for
* daily aggregation so a 23:30-local reading lands on today's bucket
* regardless of UTC offset. Every display surface in v1.4.40 anchors
* here — the legacy Berlin-only `berlinDayKey()` was retired in that
* release.
*/
export function userDayKey(date: Date, tz: string): string {
return formatInUserTz(date, tz, "date");
}
/**
* Wall-clock hour (0–23) an observer in `tz` reads off the clock at
* `date`. Used for the time-of-day greeting so a traveller whose device
* clock differs from their configured HealthLog zone still sees the right
* salutation. Falls back to the UTC hour if the zone is unusable.
*/
export function hourInTz(date: Date, tz: string): number {
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
const parsed = parseInt(wallClockParts(date, safeTz).hour, 10);
return Number.isFinite(parsed) ? parsed % 24 : date.getUTCHours();
}
interface WallClockParts {
year: string;
month: string;
day: string;
hour: string;
minute: string;
second: string;
}
function wallClockParts(date: Date, tz: string): WallClockParts {
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
const parts = fmt.formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "00";
// Some engines render midnight as "24:00".
let hour = get("hour");
if (hour === "24") hour = "00";
return {
year: get("year"),
month: get("month"),
day: get("day"),
hour,
minute: get("minute"),
second: get("second"),
};
}
/**
* Format a Date as `YYYY-MM-DDTHH:MM:SS±HH:MM` for the requested
* timezone. Sortable, lossless w.r.t. the UTC instant, and reads
* correctly in Excel / LibreOffice (the CSV-export bug in issue #167
* was triggered by `Z` getting stripped by Excel before display, so
* UTC-with-offset values are misread as local). Seconds are
* preserved (not milliseconds — the export only carries timestamps
* at second resolution anyway).
*/
function formatIsoWithOffset(date: Date, tz: string): string {
const parts = wallClockParts(date, tz);
const offsetMinutes = tzOffsetMinutes(date, tz);
const sign = offsetMinutes >= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const offH = String(Math.floor(abs / 60)).padStart(2, "0");
const offM = String(abs % 60).padStart(2, "0");
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${sign}${offH}:${offM}`;
}
/**
* Compute the UTC offset of a timezone at a specific instant, in
* minutes. Positive east of UTC, negative west. Honours DST because
* `Intl.DateTimeFormat` does — we reconstruct the offset from the
* difference between the wall-clock parts and the UTC parts.
*/
function tzOffsetMinutes(date: Date, tz: string): number {
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
const parts = fmt.formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): number =>
Number(parts.find((p) => p.type === type)?.value ?? "0");
const hour = get("hour") === 24 ? 0 : get("hour");
const asIfUtc = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
hour,
get("minute"),
get("second"),
);
return Math.round((asIfUtc - date.getTime()) / 60000);
}