UNPKG

clockwork-tz

Version:

Accurate timezone conversions and DST handling with optional deterministic IANA tzdb support

1,649 lines (1,637 loc) 51.6 kB
import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'; var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); // src/data/types.ts var NotImplementedError = class extends Error { constructor(message) { super(message); this.name = "NotImplementedError"; } }; var TimezoneError = class extends Error { constructor(message) { super(message); this.name = "TimezoneError"; } }; var AmbiguousTimeError = class extends TimezoneError { constructor(localTime, zone) { super(`Ambiguous time "${localTime}" in timezone "${zone}"`); this.name = "AmbiguousTimeError"; } }; var InvalidTimeError = class extends TimezoneError { constructor(localTime, zone) { super(`Invalid time "${localTime}" in timezone "${zone}"`); this.name = "InvalidTimeError"; } }; // src/utils/iso.ts var ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?(?:Z|([+-])(\d{2}):(\d{2}))$/; var LOCAL_ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?$/; function parseISODate(isoString) { if (!isValidISOString(isoString)) { throw new Error(`Invalid ISO date string: ${isoString}`); } const date = new Date(isoString); if (isNaN(date.getTime())) { throw new Error(`Unable to parse ISO date string: ${isoString}`); } return date; } function isValidISOString(isoString) { if (typeof isoString !== "string") { return false; } return ISO_DATE_PATTERN.test(isoString); } function isValidLocalISOString(localString) { if (typeof localString !== "string") { return false; } return LOCAL_ISO_PATTERN.test(localString); } function parseLocalISOString(localString) { if (!isValidLocalISOString(localString)) { throw new Error(`Invalid local ISO string: ${localString}`); } const match = LOCAL_ISO_PATTERN.exec(localString); if (!match) { throw new Error(`Failed to parse local ISO string: ${localString}`); } const [, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr, msStr] = match; return { year: parseInt(yearStr, 10), month: parseInt(monthStr, 10), day: parseInt(dayStr, 10), hour: parseInt(hourStr, 10), minute: parseInt(minuteStr, 10), second: parseInt(secondStr, 10), millisecond: msStr ? parseInt(msStr, 10) : 0 }; } function componentsToDate(components) { return new Date( components.year, components.month - 1, // JS months are 0-based components.day, components.hour, components.minute, components.second, components.millisecond ); } function formatAsUTC(date) { return date.toISOString(); } function formatAsLocalISO(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hour = String(date.getHours()).padStart(2, "0"); const minute = String(date.getMinutes()).padStart(2, "0"); const second = String(date.getSeconds()).padStart(2, "0"); const ms = String(date.getMilliseconds()).padStart(3, "0"); return `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}`; } function normalizeToDate(input) { if (input instanceof Date) { return input; } if (typeof input === "string") { return parseISODate(input); } throw new Error(`Invalid date input: ${input}`); } function isLeapYear(year) { return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0; } function getDaysInMonth(year, month) { const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (month === 2 && isLeapYear(year)) { return 29; } return daysInMonth[month - 1] ?? 30; } function validateDateComponents(components) { const { year, month, day, hour, minute, second, millisecond } = components; if (year < 1970 || year > 3e3) return false; if (month < 1 || month > 12) return false; if (day < 1 || day > getDaysInMonth(year, month)) return false; if (hour < 0 || hour > 23) return false; if (minute < 0 || minute > 59) return false; if (second < 0 || second > 59) return false; if (millisecond < 0 || millisecond > 999) return false; return true; } function addMilliseconds(date, ms) { return new Date(date.getTime() + ms); } function diffInMilliseconds(date1, date2) { return date1.getTime() - date2.getTime(); } // src/engines/systemIntl.ts var SystemIntlEngine = class { constructor() { this.kind = "system-intl"; } getVersion() { return void 0; } isValidTimeZone(zone) { try { new Intl.DateTimeFormat("en", { timeZone: zone }); return true; } catch { return false; } } getUserTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; } } fromUTC(utc, zone) { if (!this.isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } const offsetMinutes = this.getOffset(utc, zone); const isDST2 = this.isDST(utc, zone); const local = formatInTimeZone(utc, zone, "yyyy-MM-dd HH:mm:ss.SSS"); return { utc: formatAsUTC(utc), zone, local, offsetMinutes, isDST: isDST2, source: this.kind }; } interpretLocal(localISO, zone, disambiguation) { if (!this.isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } try { const components = parseLocalISOString(localISO); const localDate = componentsToDate(components); const utcFromLocal = fromZonedTime(localDate, zone); const backToLocal = toZonedTime(utcFromLocal, zone); const originalMs = localDate.getTime(); const roundTripMs = backToLocal.getTime(); if (Math.abs(originalMs - roundTripMs) > 1e3) { const hourBefore = new Date(localDate.getTime() - 60 * 60 * 1e3); const hourAfter = new Date(localDate.getTime() + 60 * 60 * 1e3); try { const utcBefore = fromZonedTime(hourBefore, zone); const utcAfter = fromZonedTime(hourAfter, zone); const offsetBefore = this.getOffset(utcBefore, zone); const offsetAfter = this.getOffset(utcAfter, zone); if (offsetBefore !== offsetAfter) { if (offsetBefore < offsetAfter) { if (disambiguation === "reject") { throw new InvalidTimeError(localISO, zone); } const adjustedLocal = new Date( localDate.getTime() + (offsetAfter - offsetBefore) * 60 * 1e3 ); return this.fromUTC(fromZonedTime(adjustedLocal, zone), zone); } else { if (disambiguation === "reject") { throw new AmbiguousTimeError(localISO, zone); } const earlierUtc = new Date( utcFromLocal.getTime() - Math.abs(offsetBefore - offsetAfter) * 60 * 1e3 ); const laterUtc = utcFromLocal; const targetUtc = disambiguation === "earliest" ? earlierUtc : laterUtc; return this.fromUTC(targetUtc, zone); } } } catch { } } return this.fromUTC(utcFromLocal, zone); } catch (error) { if (error instanceof InvalidTimeError || error instanceof AmbiguousTimeError) { throw error; } throw new TimezoneError( `Failed to interpret local time "${localISO}" in zone "${zone}": ${error}` ); } } getOffset(time, zone) { if (!this.isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } try { const zonedTime = toZonedTime(time, zone); const utcTime = time; const offsetMs = zonedTime.getTime() - utcTime.getTime(); return Math.round(offsetMs / (60 * 1e3)); } catch { try { const formatter = new Intl.DateTimeFormat("en", { timeZone: zone, timeZoneName: "shortOffset" }); const parts = formatter.formatToParts(time); const offsetPart = parts.find((part) => part.type === "timeZoneName"); if (offsetPart?.value) { return this.parseOffsetString(offsetPart.value); } } catch { } return 0; } } isDST(time, zone) { if (!this.isValidTimeZone(zone)) { return false; } try { const january = new Date(time.getFullYear(), 0, 1); const currentOffset = this.getOffset(time, zone); const winterOffset = this.getOffset(january, zone); return currentOffset > winterOffset; } catch { return false; } } listTimeZones(options = {}) { const { labelStyle = "original", forZone } = options; const commonZones = this.getCommonTimeZones(); const now = /* @__PURE__ */ new Date(); return commonZones.map((zone) => { const offsetMinutes = this.getOffset(now, zone); const label = this.formatZoneLabel(zone, offsetMinutes, labelStyle, forZone); return { value: zone, label, offsetMinutes }; }).sort((a, b) => { if (a.offsetMinutes !== b.offsetMinutes) { return a.offsetMinutes - b.offsetMinutes; } return a.value.localeCompare(b.value); }); } format(time, zone, pattern) { if (!this.isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } try { return formatInTimeZone(time, zone, pattern); } catch (error) { throw new TimezoneError(`Failed to format time in zone "${zone}": ${error}`); } } parseOffsetString(offsetStr) { const match = offsetStr.match(/([+-])(\d{1,2}):?(\d{2})?/); if (!match) { return 0; } const [, sign, hours, minutes = "0"] = match; const offsetMinutes = parseInt(hours, 10) * 60 + parseInt(minutes ?? "0", 10); return sign === "+" ? offsetMinutes : -offsetMinutes; } formatZoneLabel(zone, offsetMinutes, style, _forZone) { const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60); const offsetMins = Math.abs(offsetMinutes) % 60; const offsetSign = offsetMinutes >= 0 ? "+" : "-"; const offsetStr = `${offsetSign}${offsetHours.toString().padStart(2, "0")}:${offsetMins.toString().padStart(2, "0")}`; switch (style) { case "offset": { return `(UTC${offsetStr}) ${zone}`; } case "abbrev": { try { const formatter = new Intl.DateTimeFormat("en", { timeZone: zone, timeZoneName: "short" }); const parts = formatter.formatToParts(/* @__PURE__ */ new Date()); const abbrev = parts.find((part) => part.type === "timeZoneName")?.value; return abbrev ? `${zone} (${abbrev})` : zone; } catch { return zone; } } case "altName": { try { const formatter = new Intl.DateTimeFormat("en", { timeZone: zone, timeZoneName: "long" }); const parts = formatter.formatToParts(/* @__PURE__ */ new Date()); const longName = parts.find((part) => part.type === "timeZoneName")?.value; return longName && longName !== zone ? `${zone} (${longName})` : zone; } catch { return zone; } } default: { return zone; } } } getCommonTimeZones() { return [ "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Anchorage", "Pacific/Honolulu", "America/St_Johns", "America/Halifax", "America/Toronto", "America/Winnipeg", "America/Edmonton", "America/Vancouver", "Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Rome", "Europe/Madrid", "Europe/Amsterdam", "Europe/Brussels", "Europe/Zurich", "Europe/Vienna", "Europe/Prague", "Europe/Warsaw", "Europe/Stockholm", "Europe/Oslo", "Europe/Copenhagen", "Europe/Helsinki", "Europe/Moscow", "Europe/Kiev", "Europe/Istanbul", "Europe/Athens", "Asia/Tokyo", "Asia/Seoul", "Asia/Shanghai", "Asia/Hong_Kong", "Asia/Singapore", "Asia/Bangkok", "Asia/Jakarta", "Asia/Manila", "Asia/Taipei", "Asia/Kuala_Lumpur", "Asia/Kolkata", "Asia/Dubai", "Asia/Karachi", "Asia/Dhaka", "Asia/Kathmandu", "Asia/Colombo", "Australia/Sydney", "Australia/Melbourne", "Australia/Brisbane", "Australia/Perth", "Australia/Adelaide", "Australia/Darwin", "Australia/Lord_Howe", "Australia/Eucla", "Pacific/Auckland", "Pacific/Fiji", "Pacific/Chatham", "Pacific/Tahiti", "Pacific/Marquesas", "Pacific/Pitcairn", "Africa/Cairo", "Africa/Lagos", "Africa/Johannesburg", "Africa/Nairobi", "Africa/Casablanca", "America/Sao_Paulo", "America/Argentina/Buenos_Aires", "America/Santiago", "America/Lima", "America/Bogota", "America/Caracas", "America/Mexico_City", "America/Guatemala", "America/Jamaica" ]; } }; function createSystemIntlEngine() { return new SystemIntlEngine(); } // src/engines/embeddedTzdb.ts var EmbeddedTzdbEngine = class { constructor(tzdbData, version) { this.kind = "embedded-tzdb"; this.tzdbData = tzdbData; this.tzdbVersion = version; } getVersion() { return this.tzdbVersion; } isValidTimeZone(_zone) { throw new NotImplementedError("EmbeddedTzdbEngine.isValidTimeZone not yet implemented"); } getUserTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; } } fromUTC(_utc, _zone) { throw new NotImplementedError("EmbeddedTzdbEngine.fromUTC not yet implemented"); } interpretLocal(_localISO, _zone, _disambiguation) { throw new NotImplementedError("EmbeddedTzdbEngine.interpretLocal not yet implemented"); } getOffset(_time, _zone) { throw new NotImplementedError("EmbeddedTzdbEngine.getOffset not yet implemented"); } isDST(_time, _zone) { throw new NotImplementedError("EmbeddedTzdbEngine.isDST not yet implemented"); } listTimeZones(_options = {}) { throw new NotImplementedError("EmbeddedTzdbEngine.listTimeZones not yet implemented"); } format(_time, _zone, _pattern) { throw new NotImplementedError("EmbeddedTzdbEngine.format not yet implemented"); } /** * Load timezone database data * TODO: Implement loading from various sources: * - Embedded JSON data * - CDN-fetched data * - Local cache */ async loadTzdbData(data, version) { this.tzdbData = data; this.tzdbVersion = version; throw new NotImplementedError("EmbeddedTzdbEngine.loadTzdbData not yet implemented"); } /** * Get the raw TZDB data (for debugging/inspection) */ getTzdbData() { return this.tzdbData; } /** * Check if TZDB data is loaded */ isDataLoaded() { return this.tzdbData != null && this.tzdbVersion != null; } }; function createEmbeddedTzdbEngine(tzdbData, version) { return new EmbeddedTzdbEngine(tzdbData, version); } // src/utils/env.ts function detectEnvironment() { const isNode = typeof process !== "undefined" && process.versions != null && process.versions.node != null; const isBrowser = typeof window !== "undefined" && typeof document !== "undefined"; const hasBroadcastChannel = typeof BroadcastChannel !== "undefined"; const hasLocalStorage = (() => { try { return typeof localStorage !== "undefined" && localStorage !== null; } catch { return false; } })(); const hasCacheAPI = typeof caches !== "undefined"; const hasFileSystem = isNode && typeof __require !== "undefined"; return { isNode, isBrowser, hasBroadcastChannel, hasLocalStorage, hasCacheAPI, hasFileSystem }; } function getCacheStorage() { const env = detectEnvironment(); if (env.hasLocalStorage) { return { async get(key) { try { const item = localStorage.getItem(key); if (!item) { return null; } const parsed = JSON.parse(item); if (parsed.expires && Date.now() > parsed.expires) { localStorage.removeItem(key); return null; } return parsed.value; } catch { return null; } }, async set(key, value, ttl) { try { const item = { value, expires: ttl ? Date.now() + ttl : void 0 }; localStorage.setItem(key, JSON.stringify(item)); } catch { } }, async delete(key) { try { localStorage.removeItem(key); } catch { } } }; } if (env.isNode) { const cache = /* @__PURE__ */ new Map(); return { async get(key) { const item = cache.get(key); if (!item) { return null; } if (item.expires && Date.now() > item.expires) { cache.delete(key); return null; } return item.value; }, async set(key, value, ttl) { cache.set(key, { value, expires: ttl ? Date.now() + ttl : void 0 }); }, async delete(key) { cache.delete(key); } }; } return { async get() { return null; }, async set() { }, async delete() { } }; } function createBroadcastChannel(channelName) { const env = detectEnvironment(); if (env.hasBroadcastChannel) { const channel = new BroadcastChannel(channelName); return { postMessage: (data) => channel.postMessage(data), addEventListener: (_type, listener) => { channel.addEventListener("message", (event) => { listener({ data: event.data }); }); }, close: () => channel.close() }; } return null; } var cachedEnvironment = null; function getEnvironment() { if (!cachedEnvironment) { cachedEnvironment = detectEnvironment(); } return cachedEnvironment; } function isSecureContext() { if (typeof window !== "undefined") { return window.isSecureContext ?? false; } return true; } // src/data/updater.ts var DEFAULT_MANIFEST_URL = "https://cdn.clockwork-tz.dev/manifest.json"; var CACHE_KEY_PREFIX = "clockwork-tz:"; var BROADCAST_CHANNEL_NAME = "clockwork-tz-updates"; var MANIFEST_CACHE_TTL = 60 * 60 * 1e3; var TZDB_CACHE_TTL = 24 * 60 * 60 * 1e3; async function ensureFreshTzdb(manifestURL = DEFAULT_MANIFEST_URL) { const cache = getCacheStorage(); const manifestCacheKey = `${CACHE_KEY_PREFIX}manifest`; const dataCacheKey = `${CACHE_KEY_PREFIX}data`; try { const cachedDataStr = await cache.get(dataCacheKey); if (cachedDataStr) { const cachedData2 = JSON.parse(cachedDataStr); const age = Date.now() - cachedData2.cachedAt; if (age < TZDB_CACHE_TTL) { return { version: cachedData2.version, data: cachedData2.data }; } } const manifest = await fetchManifest(manifestURL, cache, manifestCacheKey); if (!manifest) { if (cachedDataStr) { const cachedData2 = JSON.parse(cachedDataStr); return { version: cachedData2.version, data: cachedData2.data }; } return null; } const cachedData = cachedDataStr ? JSON.parse(cachedDataStr) : null; if (cachedData && cachedData.version === manifest.version) { const updatedCache = { ...cachedData, cachedAt: Date.now() }; await cache.set(dataCacheKey, JSON.stringify(updatedCache), TZDB_CACHE_TTL); return { version: cachedData.version, data: cachedData.data }; } const tzdbData = await fetchTzdbData(manifest); if (!tzdbData) { if (cachedData) { return { version: cachedData.version, data: cachedData.data }; } return null; } const isValid = await verifySignature(tzdbData, manifest.signature); if (!isValid) { console.warn("Timezone data signature verification failed, using cached data if available"); if (cachedData) { return { version: cachedData.version, data: cachedData.data }; } return null; } const newCachedData = { version: manifest.version, cachedAt: Date.now(), etag: manifest.etag ?? void 0, data: tzdbData }; await cache.set(dataCacheKey, JSON.stringify(newCachedData), TZDB_CACHE_TTL); broadcastUpdate(manifest.version); return { version: manifest.version, data: tzdbData }; } catch (error) { console.warn("Failed to ensure fresh timezone data:", error); try { const cachedDataStr = await cache.get(dataCacheKey); if (cachedDataStr) { const cachedData = JSON.parse(cachedDataStr); return { version: cachedData.version, data: cachedData.data }; } } catch { } return null; } } async function fetchManifest(manifestURL, cache, cacheKey) { try { const cachedManifestStr = await cache.get(cacheKey); let etag; if (cachedManifestStr) { const cachedManifest = JSON.parse(cachedManifestStr); etag = cachedManifest.etag; } const fetchOptions = { method: "GET", headers: { Accept: "application/json", "Cache-Control": "no-cache" } }; if (etag) { fetchOptions.headers = { ...fetchOptions.headers, "If-None-Match": etag }; } const response = await fetch(manifestURL, fetchOptions); if (response.status === 304) { return cachedManifestStr ? JSON.parse(cachedManifestStr) : null; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const manifest = await response.json(); const responseEtag = response.headers.get("etag"); if (responseEtag) { manifest.etag = responseEtag; } await cache.set(cacheKey, JSON.stringify(manifest), MANIFEST_CACHE_TTL); return manifest; } catch (error) { console.warn("Failed to fetch timezone manifest:", error); return null; } } async function fetchTzdbData(manifest) { try { const response = await fetch(manifest.dataUrl, { method: "GET", headers: { Accept: "application/json", "Cache-Control": "no-cache" } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { console.warn("Failed to fetch timezone data:", error); return null; } } async function verifySignature(data, signature) { console.debug("Signature verification (stub):", { dataSize: JSON.stringify(data).length, signature: signature.slice(0, 16) + "..." }); return true; } function broadcastUpdate(version) { const channel = createBroadcastChannel(BROADCAST_CHANNEL_NAME); if (channel) { try { channel.postMessage({ type: "tzdb-update", version, timestamp: Date.now() }); } catch (error) { console.warn("Failed to broadcast timezone update:", error); } } } function onDataUpdate(callback) { const channel = createBroadcastChannel(BROADCAST_CHANNEL_NAME); if (!channel) { return () => { }; } const handleMessage = (event) => { try { const message = event.data; if (message.type === "tzdb-update" && typeof message.version === "string") { callback(message.version); } } catch (error) { console.warn("Failed to handle timezone update message:", error); } }; channel.addEventListener("message", handleMessage); return () => { try { channel.close(); } catch { } }; } async function clearCache() { const cache = getCacheStorage(); try { await Promise.all([ cache.delete(`${CACHE_KEY_PREFIX}manifest`), cache.delete(`${CACHE_KEY_PREFIX}data`) ]); } catch (error) { console.warn("Failed to clear timezone cache:", error); } } async function getCacheStats() { const cache = getCacheStorage(); try { const [manifestStr, dataStr] = await Promise.all([ cache.get(`${CACHE_KEY_PREFIX}manifest`), cache.get(`${CACHE_KEY_PREFIX}data`) ]); const stats = { manifestCached: manifestStr != null, dataCached: dataStr != null, dataVersion: void 0, dataAge: void 0 }; if (dataStr) { const cachedData = JSON.parse(dataStr); stats.dataVersion = cachedData.version; stats.dataAge = Date.now() - cachedData.cachedAt; } return { manifestCached: stats.manifestCached, dataCached: stats.dataCached, dataVersion: stats.dataVersion ?? void 0, dataAge: stats.dataAge ?? void 0 }; } catch (error) { console.warn("Failed to get cache stats:", error); return { manifestCached: false, dataCached: false }; } } // src/transitions.ts var activeWatchers = /* @__PURE__ */ new Set(); var activeTickWatchers = /* @__PURE__ */ new Set(); function watchZoneTransitions(zone, callback, engine2) { if (!engine2) { console.warn("No engine provided to watchZoneTransitions, cannot detect transitions"); return () => { }; } if (!engine2.isValidTimeZone(zone)) { console.warn(`Invalid timezone "${zone}" for transition watching`); return () => { }; } let isActive = true; let currentTimeoutId = null; const scheduleNextCheck = () => { if (!isActive) return; const now = /* @__PURE__ */ new Date(); const nextTransition = findNextTransition(now, zone, engine2); if (nextTransition) { const msUntilTransition = nextTransition.getTime() - now.getTime(); const callbackDelay = Math.max(0, msUntilTransition + 1e3); currentTimeoutId = setTimeout(() => { if (!isActive) return; callback(nextTransition); scheduleNextCheck(); }, callbackDelay); } else { currentTimeoutId = setTimeout(scheduleNextCheck, 24 * 60 * 60 * 1e3); } }; scheduleNextCheck(); const cleanup = () => { isActive = false; if (currentTimeoutId) { clearTimeout(currentTimeoutId); currentTimeoutId = null; } activeWatchers.delete(cleanup); }; activeWatchers.add(cleanup); return cleanup; } function findNextTransition(from, zone, engine2) { try { const currentOffset = engine2.getOffset(from, zone); const searchStart = new Date(from.getTime()); let searchDate = new Date(searchStart.getTime()); const maxSearchWeeks = 104; for (let week = 0; week < maxSearchWeeks; week++) { searchDate.setTime(searchStart.getTime() + week * 7 * 24 * 60 * 60 * 1e3); const searchOffset = engine2.getOffset(searchDate, zone); if (searchOffset !== currentOffset) { return binarySearchTransition( new Date(searchStart.getTime() + (week - 1) * 7 * 24 * 60 * 60 * 1e3), searchDate, zone, engine2, currentOffset ); } } return null; } catch (error) { console.warn(`Failed to find next transition for zone "${zone}":`, error); return null; } } function binarySearchTransition(start, end, zone, engine2, expectedStartOffset) { try { let left = start.getTime(); let right = end.getTime(); const precision = 60 * 1e3; while (right - left > precision) { const mid = Math.floor((left + right) / 2); const midDate = new Date(mid); const midOffset = engine2.getOffset(midDate, zone); if (midOffset === expectedStartOffset) { left = mid; } else { right = mid; } } return new Date(right); } catch (error) { console.warn(`Failed to binary search transition for zone "${zone}":`, error); return null; } } function onTick(callback, granularityMs = 1e3) { if (granularityMs < 100) { console.warn("Tick granularity less than 100ms may impact performance"); granularityMs = 100; } const tick = () => { callback(/* @__PURE__ */ new Date()); }; tick(); const intervalId = setInterval(tick, granularityMs); const watcher = { callback, interval: granularityMs, timeoutId: intervalId }; activeTickWatchers.add(watcher); const cleanup = () => { clearInterval(intervalId); activeTickWatchers.delete(watcher); }; return cleanup; } function getNextTransition(zone, from = /* @__PURE__ */ new Date(), engine2) { if (!engine2) { return null; } if (!engine2.isValidTimeZone(zone)) { return null; } try { const currentOffset = engine2.getOffset(from, zone); const transitionDate = findNextTransition(from, zone, engine2); if (!transitionDate) { return null; } const newOffset = engine2.getOffset(transitionDate, zone); const isDSTStart = newOffset > currentOffset; return { date: transitionDate, fromOffset: currentOffset, toOffset: newOffset, isDSTStart }; } catch (error) { console.warn(`Failed to get next transition for zone "${zone}":`, error); return null; } } function getPreviousTransition(zone, from = /* @__PURE__ */ new Date(), engine2) { if (!engine2) { return null; } if (!engine2.isValidTimeZone(zone)) { return null; } try { const currentOffset = engine2.getOffset(from, zone); const searchStart = new Date(from.getTime()); let searchDate = new Date(searchStart.getTime()); const maxSearchWeeks = 104; for (let week = 1; week <= maxSearchWeeks; week++) { searchDate.setTime(searchStart.getTime() - week * 7 * 24 * 60 * 60 * 1e3); const searchOffset = engine2.getOffset(searchDate, zone); if (searchOffset !== currentOffset) { const transitionDate = binarySearchTransition( searchDate, new Date(searchStart.getTime() - (week - 1) * 7 * 24 * 60 * 60 * 1e3), zone, engine2, searchOffset ); if (transitionDate) { const isDSTStart = currentOffset > searchOffset; return { date: transitionDate, fromOffset: searchOffset, toOffset: currentOffset, isDSTStart }; } } } return null; } catch (error) { console.warn(`Failed to get previous transition for zone "${zone}":`, error); return null; } } function isNearTransition(date, zone, engine2, windowHours = 24) { if (!engine2?.isValidTimeZone(zone)) { return false; } try { const windowMs = windowHours * 60 * 60 * 1e3; const before = new Date(date.getTime() - windowMs); const after = new Date(date.getTime() + windowMs); const offsetBefore = engine2.getOffset(before, zone); const offsetAt = engine2.getOffset(date, zone); const offsetAfter = engine2.getOffset(after, zone); return offsetBefore !== offsetAt || offsetAt !== offsetAfter; } catch { return false; } } function cleanupAllWatchers() { for (const cleanup of activeWatchers) { try { cleanup(); } catch (error) { console.warn("Error cleaning up transition watcher:", error); } } activeWatchers.clear(); for (const watcher of activeTickWatchers) { try { clearInterval(watcher.timeoutId); } catch (error) { console.warn("Error cleaning up tick watcher:", error); } } activeTickWatchers.clear(); } function getWatcherStats() { return { transitionWatchers: activeWatchers.size, tickWatchers: activeTickWatchers.size }; } var DEFAULT_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss zzz"; var FORMAT_PATTERNS = { /** ISO-like with timezone abbreviation: 2024-03-15 14:30:00 PST */ default: DEFAULT_FORMAT_PATTERN, /** ISO date only: 2024-03-15 */ dateOnly: "yyyy-MM-dd", /** ISO time only: 14:30:00 */ timeOnly: "HH:mm:ss", /** ISO datetime without timezone: 2024-03-15 14:30:00 */ dateTime: "yyyy-MM-dd HH:mm:ss", /** ISO datetime with milliseconds: 2024-03-15 14:30:00.123 */ dateTimeMs: "yyyy-MM-dd HH:mm:ss.SSS", /** 12-hour format: 2024-03-15 2:30:00 PM PST */ amPm: "yyyy-MM-dd h:mm:ss a zzz", /** Human readable: March 15, 2024 at 2:30 PM PST */ human: "MMMM d, yyyy 'at' h:mm a zzz", /** Short format: 3/15/24 2:30 PM */ short: "M/d/yy h:mm a", /** Long format with full timezone name */ long: "EEEE, MMMM d, yyyy 'at' h:mm:ss a zzzz", /** RFC 2822 style: Fri, 15 Mar 2024 14:30:00 +0800 */ rfc2822: "EEE, d MMM yyyy HH:mm:ss xx", /** ISO 8601 with offset: 2024-03-15T14:30:00+08:00 */ iso8601: "yyyy-MM-dd'T'HH:mm:ssxxx" }; function format(input, zone, pattern = DEFAULT_FORMAT_PATTERN) { try { const date = normalizeToDate(input); return formatInTimeZone(date, zone, pattern); } catch (error) { throw new TimezoneError(`Failed to format date in timezone "${zone}": ${error}`); } } function formatWithPattern(input, zone, patternName) { const pattern = FORMAT_PATTERNS[patternName]; if (!pattern) { throw new Error(`Unknown format pattern: ${patternName}`); } return format(input, zone, pattern); } function formatNow(zone, pattern) { return format(/* @__PURE__ */ new Date(), zone, pattern); } function formatFor(input, zone, useCase) { switch (useCase) { case "display": { return format(input, zone, FORMAT_PATTERNS.default); } case "log": { return format(input, zone, FORMAT_PATTERNS.iso8601); } case "filename": { return format(input, zone, "yyyy-MM-dd_HH-mm-ss"); } case "api": { return format(input, zone, FORMAT_PATTERNS.iso8601); } case "human": { return format(input, zone, FORMAT_PATTERNS.human); } default: { return format(input, zone, FORMAT_PATTERNS.default); } } } function formatRange(start, end, zone, options = {}) { const { pattern = FORMAT_PATTERNS.default, separator = " \u2013 ", sameDay = true } = options; const startDate = normalizeToDate(start); const endDate = normalizeToDate(end); if (sameDay) { const startDay = format(startDate, zone, "yyyy-MM-dd"); const endDay = format(endDate, zone, "yyyy-MM-dd"); if (startDay === endDay) { const dateStr = format(startDate, zone, "yyyy-MM-dd"); const startTime = format(startDate, zone, "HH:mm"); const endTime = format(endDate, zone, "HH:mm zzz"); return `${dateStr} ${startTime}${separator}${endTime}`; } } const startStr = format(startDate, zone, pattern); const endStr = format(endDate, zone, pattern); return `${startStr}${separator}${endStr}`; } function formatDuration(start, end, options = {}) { const { units = ["days", "hours", "minutes"], precision = 2 } = options; const startDate = normalizeToDate(start); const endDate = normalizeToDate(end); const diffMs = Math.abs(endDate.getTime() - startDate.getTime()); const parts = []; let remaining = diffMs; if (units.includes("days")) { const days = Math.floor(remaining / (24 * 60 * 60 * 1e3)); if (days > 0) { parts.push(`${days} day${days !== 1 ? "s" : ""}`); remaining %= 24 * 60 * 60 * 1e3; } } if (units.includes("hours")) { const hours = Math.floor(remaining / (60 * 60 * 1e3)); if (hours > 0 || parts.length > 0) { parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`); remaining %= 60 * 60 * 1e3; } } if (units.includes("minutes")) { const minutes = Math.floor(remaining / (60 * 1e3)); if (minutes > 0 || parts.length > 0) { parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`); remaining %= 60 * 1e3; } } if (units.includes("seconds")) { const seconds = Math.floor(remaining / 1e3); if (seconds > 0 || parts.length === 0) { parts.push(`${seconds} second${seconds !== 1 ? "s" : ""}`); } } return parts.slice(0, precision).join(", "); } function formatRelative(date, relativeTo = /* @__PURE__ */ new Date(), zone) { const targetDate = normalizeToDate(date); const baseDate = normalizeToDate(relativeTo); const diffMs = targetDate.getTime() - baseDate.getTime(); const isPast = diffMs < 0; const absDiffMs = Math.abs(diffMs); const seconds = Math.floor(absDiffMs / 1e3); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); let timeStr; let unit; if (seconds < 60) { timeStr = seconds.toString(); unit = seconds === 1 ? "second" : "seconds"; } else if (minutes < 60) { timeStr = minutes.toString(); unit = minutes === 1 ? "minute" : "minutes"; } else if (hours < 24) { timeStr = hours.toString(); unit = hours === 1 ? "hour" : "hours"; } else if (days < 7) { timeStr = days.toString(); unit = days === 1 ? "day" : "days"; } else { return format(targetDate, zone, FORMAT_PATTERNS.dateOnly); } return isPast ? `${timeStr} ${unit} ago` : `in ${timeStr} ${unit}`; } function formatOffset(offsetMinutes) { const sign = offsetMinutes >= 0 ? "+" : "-"; const absMinutes = Math.abs(offsetMinutes); const hours = Math.floor(absMinutes / 60); const minutes = absMinutes % 60; return `${sign}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; } function isValidFormatPattern(pattern) { try { format(/* @__PURE__ */ new Date(), "UTC", pattern); return true; } catch { return false; } } // src/api.ts var currentEngine = createSystemIntlEngine(); var initOptions = {}; var updateListeners = /* @__PURE__ */ new Set(); function initClockworkTZ(opts = {}) { initOptions = { ...opts }; const { engine: engine2 = "system-intl", manifestURL, autoUpdateIntervalMs = 24 * 60 * 60 * 1e3 } = opts; if (engine2 !== currentEngine.kind) { switch (engine2) { case "system-intl": { currentEngine = createSystemIntlEngine(); break; } case "embedded-tzdb": { currentEngine = createEmbeddedTzdbEngine(); if (manifestURL) { ensureFreshTzdb(manifestURL).then((result) => { if (result && "loadTzdbData" in currentEngine) { return currentEngine.loadTzdbData(result.data, result.version); } }).catch((error) => { console.warn("Failed to load initial timezone data:", error); }); } break; } default: { throw new Error(`Unknown engine: ${engine2}`); } } } if (engine2 === "embedded-tzdb" && manifestURL && autoUpdateIntervalMs > 0) { const updateInterval = setInterval(async () => { try { const result = await ensureFreshTzdb(manifestURL); if (result && "loadTzdbData" in currentEngine) { await currentEngine.loadTzdbData(result.data, result.version); for (const listener of updateListeners) { try { listener(result.version); } catch (error) { console.warn("Error in data update listener:", error); } } } } catch (error) { console.warn("Auto-update failed:", error); } }, autoUpdateIntervalMs); if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { clearInterval(updateInterval); }); } } } function engine() { return currentEngine.kind; } function tzdbVersion() { return currentEngine.getVersion(); } function getUserTimeZone() { return currentEngine.getUserTimeZone(); } function isValidTimeZone(zone) { if (typeof zone !== "string" || zone.trim() === "") { return false; } return currentEngine.isValidTimeZone(zone); } function fromUTC(input, zone, opts = {}) { if (!isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } const date = normalizeToDate(input); const result = currentEngine.fromUTC(date, zone); if (opts.format && opts.format !== DEFAULT_FORMAT_PATTERN) { result.local = currentEngine.format(date, zone, opts.format); } return result; } function interpretLocal(localISO, zone, opts = {}) { if (!isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } const { disambiguation = "latest", format: format3 } = opts; const result = currentEngine.interpretLocal(localISO, zone, disambiguation); if (format3 && format3 !== DEFAULT_FORMAT_PATTERN) { const utcDate = new Date(result.utc); result.local = currentEngine.format(utcDate, zone, format3); } return result; } function convert(input, fromZone, toZone, opts = {}) { if (!isValidTimeZone(fromZone)) { throw new TimezoneError(`Invalid source timezone: ${fromZone}`); } if (!isValidTimeZone(toZone)) { throw new TimezoneError(`Invalid target timezone: ${toZone}`); } let utcTime; if (input instanceof Date) { utcTime = input; } else if (typeof input === "string") { if (input.endsWith("Z") || /[+-]\d{2}:?\d{2}$/.test(input)) { utcTime = normalizeToDate(input); } else { const sourceResult = interpretLocal(input, fromZone, opts); utcTime = new Date(sourceResult.utc); } } else { throw new Error("Input must be a Date object or ISO string"); } return fromUTC(utcTime, toZone, opts); } function format2(input, zone, pattern = DEFAULT_FORMAT_PATTERN) { if (!isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } const date = normalizeToDate(input); return format(date, zone, pattern); } function getOffset(input, zone) { if (!isValidTimeZone(zone)) { throw new TimezoneError(`Invalid timezone: ${zone}`); } const date = normalizeToDate(input); return currentEngine.getOffset(date, zone); } function isDST(input, zone) { if (!isValidTimeZone(zone)) { return false; } const date = normalizeToDate(input); return currentEngine.isDST(date, zone); } function listTimeZones(opts = {}) { return currentEngine.listTimeZones(opts); } function onDataUpdate2(callback) { updateListeners.add(callback); const broadcastCleanup = onDataUpdate(callback); return () => { updateListeners.delete(callback); broadcastCleanup(); }; } function watchZoneTransitions2(zone, callback) { return watchZoneTransitions(zone, callback, currentEngine); } function onTick2(callback, granularityMs = 1e3) { return onTick(callback, granularityMs); } function getCurrentEngine() { return currentEngine; } function setEngine(engine2) { currentEngine = engine2; } function getInitOptions() { return { ...initOptions }; } async function refreshTimezoneData() { if (currentEngine.kind !== "embedded-tzdb") { throw new Error("Timezone data refresh only available for embedded-tzdb engine"); } const { manifestURL } = initOptions; if (!manifestURL) { throw new Error("No manifest URL configured for timezone data refresh"); } const result = await ensureFreshTzdb(manifestURL); if (result && "loadTzdbData" in currentEngine) { await currentEngine.loadTzdbData(result.data, result.version); for (const listener of updateListeners) { try { listener(result.version); } catch (error) { console.warn("Error in data update listener:", error); } } } } function getStatus() { const isDataLoaded = currentEngine.kind === "system-intl" || currentEngine.kind === "embedded-tzdb" && "isDataLoaded" in currentEngine && currentEngine.isDataLoaded(); return { engine: currentEngine.kind, tzdbVersion: currentEngine.getVersion() ?? void 0, dataLoaded: isDataLoaded, lastUpdate: void 0 // TODO: Track last update timestamp }; } // src/parse.ts function parse(input, options = {}) { const { strict = true, allowFlexible = false } = options; if (typeof input !== "string" || input.trim() === "") { const error = new Error("Input must be a non-empty string"); if (strict) throw error; return { date: /* @__PURE__ */ new Date(NaN), hasTimezone: false, original: input, isValid: false }; } const trimmedInput = input.trim(); if (isValidISOString(trimmedInput)) { try { const date = new Date(trimmedInput); return { date, hasTimezone: true, original: input, isValid: !isNaN(date.getTime()) }; } catch (error) { if (strict) throw new Error(`Failed to parse ISO date: ${error}`); } } if (isValidLocalISOString(trimmedInput)) { try { const components = parseLocalISOString(trimmedInput); const date = componentsToDate(components); return { date, hasTimezone: false, original: input, isValid: !isNaN(date.getTime()) && validateDateComponents(components) }; } catch (error) { if (strict) throw new Error(`Failed to parse local ISO date: ${error}`); } } if (allowFlexible && trimmedInput.includes(" ")) { const flexibleInput = trimmedInput.replace(" ", "T"); if (isValidLocalISOString(flexibleInput)) { try { const components = parseLocalISOString(flexibleInput); const date = componentsToDate(components); return { date, hasTimezone: false, original: input, isValid: !isNaN(date.getTime()) && validateDateComponents(components) }; } catch (error) { if (strict) throw new Error(`Failed to parse flexible date: ${error}`); } } } if (!strict) { try { const date = new Date(trimmedInput); return { date, hasTimezone: /[+-]\d{2}:?\d{2}|Z$/i.test(trimmedInput), original: input, isValid: !isNaN(date.getTime()) }; } catch { return { date: /* @__PURE__ */ new Date(NaN), hasTimezone: false, original: input, isValid: false }; } } throw new Error(`Invalid date format: ${input}. Expected ISO 8601 format.`); } function parseDate(input, options = {}) { const result = parse(input, options); if (!result.isValid) { throw new Error(`Invalid date: ${input}`); } return result.date; } function parseComponents(input) { if (!isValidLocalISOString(input)) { throw new Error(`Invalid local ISO format: ${input}`); } return parseLocalISOString(input); } function safeParse(input, options = {}) { try { return parse(input, { ...options, strict: false }); } catch { return null; } } function isParseable(input, options = {}) { const result = safeParse(input, options); return result?.isValid ?? false; } function parseAny(input, formats = [ { strict: true }, { strict: true, allowFlexible: true }, { strict: false } ]) { for (const format3 of formats) { const result = safeParse(input, format3); if (result?.isValid) { return result; } } throw new Error(`Unable to parse date with any format: ${input}`); } function parseRange(input, options = {}) { const { separator = /\s+(?:to|–|—|-)\s+/i, ...parseOptions } = options; const parts = input.split(separator); if (parts.length !== 2) { throw new Error(`Invalid date range format: ${input}`); } const start = parseDate(parts[0].trim(), parseOptions); const end = parseDate(parts[1].trim(), parseOptions); if (start > end) { throw new Error(`Invalid date range: start date is after end date`); } return { start, end }; } function parseTime(input) { const timePattern = /^(\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?$/; const match = timePattern.exec(input.trim()); if (!match) { throw new Error(`Invalid time format: ${input}`); } const [, hourStr, minuteStr, secondStr = "0", msStr = "0"] = match; const hour = parseInt(hourStr, 10); const minute = parseInt(minuteStr, 10); const second = parseInt(secondStr, 10); const millisecond = parseInt(msStr.padEnd(3, "0"), 10); if (hour < 0 || hour > 23) { throw new Error(`Invalid hour: ${hour}`); } if (minute < 0 || minute > 59) { throw new Error(`Invalid minute: ${minute}`); } if (second < 0 || second > 59) { throw new Error(`Invalid second: ${second}`); } return { hour, minute, second, millisecond }; } function parseOffset(input) { const trimmed = input.trim(); if (trimmed === "Z" || trimmed.toLowerCase() === "utc") { return 0; } const offsetPattern = /^([+-])(\d{1,2}):?(\d{2})$/; const match = offsetPattern.exec(trimmed); if (!match) { throw new Error(`Invalid timezone offset format: ${input}`); } const [, sign, hourStr, minuteStr] = match; const hours = parseInt(hourStr, 10); const minutes = parseInt(minuteStr, 10); if (hours > 18 || minutes > 59) { throw new Error(`Invalid timezone offset: ${input}`); } const totalMinutes = hours * 60 + minutes; return sign === "+" ? totalMinutes : -totalMinutes; } function normalize(input) { if (input instanceof Date) { return input; } if (typeof input === "number") { return new Date(input); } if (typeof input === "string") { return parseDate(input, { strict: false }); } throw new Error(`Cannot normalize input of type ${typeof input}`); } // src/index.ts var VERSION = "0.1.0"; var index_default = { // Core API init: initClockworkTZ, engine, tzdbVersion, getUserTimeZone, isValidTimeZone, fromUTC, interpretLocal, convert, format: format2, getOffset, isDST, listTimeZones, // Event handling onDataUpdate: onDataUpdate2, watchZoneTransitions: watchZoneTransitions2, onTick: onTick2, // Utilities formatTime: format2,