clockwork-tz
Version:
Accurate timezone conversions and DST handling with optional deterministic IANA tzdb support
1,649 lines (1,637 loc) • 51.6 kB
JavaScript
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,