/** * Copyright Aquera Inc 2026 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ /** * Timezone-aware wall-clock helpers for `nile-timestamp-builder`. * ALL Temporal / Intl branching lives in this module. The component must never * call `Intl.DateTimeFormat` or `Temporal` inline — it always goes through here. */ import { TIMEZONES } from './timezones'; /** The editable wall-clock fields exposed by `nile-timestamp-builder`. */ export enum TimestampField { Year = 'year', Month = 'month', Day = 'day', Hour = 'hour', Minute = 'minute', Second = 'second', Millisecond = 'millisecond', } export interface WallClock { year: number; month: number; day: number; hour: number; minute: number; second: number; millisecond: number; } export function hasTemporal(): boolean { return typeof (globalThis as any).Temporal !== 'undefined'; } /** Returns the list of supported IANA timezones using Intl or the bundled fallback list. */ export function getTimezoneList(): string[] { try { const supportedValuesOf = (Intl as any).supportedValuesOf; if (typeof supportedValuesOf === 'function') { const list = supportedValuesOf('timeZone') as string[]; if (Array.isArray(list) && list.length > 0) { return list; } } } catch { } return [...TIMEZONES]; } /** Resolves the runtime's local IANA timezone, falling back to `UTC`. */ export function getLocalTimeZone(): string { try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; if (tz && isValidTimeZone(tz)) { return tz; } } catch { } return 'UTC'; } /** Returns true when `tz` is a timezone identifier the runtime can format. */ export function isValidTimeZone(tz: string): boolean { if (!tz || typeof tz !== 'string') { return false; } try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } } export function daysInMonth(year: number, month: number): number { return new Date(Date.UTC(year, month, 0)).getUTCDate(); } /** Clamps `value` into `[min, max]`; `NaN` collapses to `min`. */ export function clampField(value: number, min: number, max: number): number { if (Number.isNaN(value)) { return min; } return Math.min(max, Math.max(min, value)); } export function clampWallClock( w: WallClock, minYear: number, maxYear: number ): WallClock { const year = clampField(Math.trunc(w.year), minYear, maxYear); const month = clampField(Math.trunc(w.month), 1, 12); const day = clampField(Math.trunc(w.day), 1, daysInMonth(year, month)); const hour = clampField(Math.trunc(w.hour), 0, 23); const minute = clampField(Math.trunc(w.minute), 0, 59); const second = clampField(Math.trunc(w.second), 0, 59); const millisecond = clampField(Math.trunc(w.millisecond), 0, 999); return { year, month, day, hour, minute, second, millisecond }; } /** Formats a wall-clock as `YYYY-MM-DD HH:mm:ss.SSS` with zero padding. */ export function formatWallClock(w: WallClock): string { const pad = (n: number, len: number) => String(Math.abs(n)).padStart(len, '0'); const y = pad(w.year, 4); const mo = pad(w.month, 2); const d = pad(w.day, 2); const h = pad(w.hour, 2); const mi = pad(w.minute, 2); const s = pad(w.second, 2); const ms = pad(w.millisecond, 3); return `${y}-${mo}-${d} ${h}:${mi}:${s}.${ms}`; } /** Converts an epoch (ms) into the wall-clock fields observed in `tz`. Uses Temporal when present, otherwise an `Intl.DateTimeFormat` fallback. */ export function epochToFields(epochMs: number, tz: string): WallClock { if (hasTemporal()) { const Temporal = (globalThis as any).Temporal; const zdt = Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(tz); return { year: zdt.year, month: zdt.month, day: zdt.day, hour: zdt.hour, minute: zdt.minute, second: zdt.second, millisecond: zdt.millisecond, }; } return intlEpochToFields(epochMs, tz); } /** Neutral wall-clock placeholder: the Unix epoch (`1970-01-01T00:00:00.000`) read in UTC.*/ export const EPOCH_WALLCLOCK: WallClock = epochToFields(0, 'UTC'); function intlEpochToFields(epochMs: number, tz: string): WallClock { const parts = formatEpochParts(epochMs, tz); const millisecond = ((epochMs % 1000) + 1000) % 1000; return { ...parts, millisecond }; } /** Extracts numeric y/mo/d/h/mi/s parts of `epochMs` in `tz` (h23, no ms). */ function formatEpochParts( epochMs: number, tz: string ): Omit { const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }); const map: Record = {}; for (const part of formatter.formatToParts(new Date(epochMs))) { if (part.type !== 'literal') { map[part.type] = Number(part.value); } } // `hourCycle: 'h23'` can still surface hour 24 for midnight in some engines. const hour = map.hour === 24 ? 0 : map.hour; return { year: map.year, month: map.month, day: map.day, hour, minute: map.minute, second: map.second, }; } /** Returns the timezone offset (ms) */ export function getTimeZoneOffsetMs(epochMs: number, tz: string): number { const parts = formatEpochParts(epochMs, tz); const asIfUTC = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second ); const epochSeconds = Math.floor(epochMs / 1000) * 1000; const offset = asIfUTC - epochSeconds; return Math.round(offset / 1000) * 1000; } const ZONED_VALUE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?)\[GMT([+-]\d{2}:\d{2}) ([^\]]+)\]$/; /** Converts a `±HH:MM` offset from {@link ZONED_VALUE_RE} into milliseconds. */ function offsetStringToMs(offset: string): number { const sign = offset[0] === '-' ? -1 : 1; return ( sign * (Number(offset.slice(1, 3)) * 3600000 + Number(offset.slice(4, 6)) * 60000) ); } export function parseZonedValue( input: string ): { date: Date; timezone: string } | null { const match = ZONED_VALUE_RE.exec(input.trim()); if (!match) { return null; } const [, iso, offset, timezone] = match; const date = new Date(`${iso}${offset}`); if (Number.isNaN(date.getTime()) || !isValidTimeZone(timezone)) { return null; } if ( getTimeZoneOffsetMs(date.getTime(), timezone) !== offsetStringToMs(offset) ) { return null; } return { date, timezone }; } /** Normalizes supported `value` inputs into epoch milliseconds, or `null` if invalid. */ export function normalizeValueInput( input: Date | number | string | null | undefined ): Date | null { if (input === null || input === undefined) { return null; } const valid = (d: Date) => (Number.isNaN(d.getTime()) ? null : d); if (input instanceof Date) { return valid(input); } if (typeof input === 'number') { return Number.isFinite(input) ? valid(new Date(input)) : null; } const trimmed = input.trim(); if (trimmed === '') { return null; } const zoned = parseZonedValue(trimmed); if (zoned) { return zoned.date; } if (/^-?\d+$/.test(trimmed)) { return valid(new Date(Number(trimmed))); } return valid(new Date(trimmed)); } /** Serializes a wall-clock into a zone-encoded string preserving its offset and IANA timezone. */ export function formatZonedValue(w: WallClock, tz: string): string { const epoch = fieldsToEpoch(w, tz); const offsetMs = Math.round(getTimeZoneOffsetMs(epoch, tz) / 60000) * 60000; const pad = (n: number, len = 2) => String(Math.abs(n)).padStart(len, '0'); const sign = offsetMs >= 0 ? '+' : '-'; const abs = Math.abs(offsetMs); const offH = Math.floor(abs / 3600000); const offM = Math.floor((abs % 3600000) / 60000); const off = `${sign}${pad(offH)}:${pad(offM)}`; const zone = tz || 'UTC'; const y = pad(w.year, 4); const mo = pad(w.month, 2); const d = pad(w.day, 2); const h = pad(w.hour, 2); const mi = pad(w.minute, 2); const s = pad(w.second, 2); const ms = pad(w.millisecond, 3); return `${y}-${mo}-${d}T${h}:${mi}:${s}.${ms}[GMT${off} ${zone}]`; } /** Converts wall-clock fields in `tz` to epoch milliseconds. */ export function fieldsToEpoch(w: WallClock, tz: string): number { if (hasTemporal()) { const Temporal = (globalThis as any).Temporal; const zdt = Temporal.ZonedDateTime.from( { year: w.year, month: w.month, day: w.day, hour: w.hour, minute: w.minute, second: w.second, millisecond: w.millisecond, timeZone: tz, }, { disambiguation: 'compatible' } ); return zdt.epochMilliseconds; } return intlFieldsToEpoch(w, tz); } function intlFieldsToEpoch(w: WallClock, tz: string): number { const asUTC = Date.UTC( w.year, w.month - 1, w.day, w.hour, w.minute, w.second, w.millisecond ); const offset0 = getTimeZoneOffsetMs(asUTC, tz); const guess = asUTC - offset0; const offset1 = getTimeZoneOffsetMs(guess, tz); if (offset0 === offset1) { return guess; } return asUTC - offset1; }