/** * Check whether a timezone string is a valid IANA identifier * that Intl.DateTimeFormat can use. */ export function isValidTimezone(tz: string): boolean { try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return true; } catch { return false; } } /** * Return the given timezone if valid IANA, otherwise fall back to UTC. */ export function resolveTimezone(tz: string): string { return isValidTimezone(tz) ? tz : 'UTC'; } /** * Convert a UTC ISO timestamp to a localized display string. * Uses the Intl API (no external dependencies). * Falls back to UTC if the timezone is invalid. */ export function formatLocalTime(utcIso: string, timezone: string): string { const date = new Date(utcIso); return date.toLocaleString('en-US', { timeZone: resolveTimezone(timezone), year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); } /** * Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`. * (sv-SE is the one locale whose default rendering is this exact shape.) */ export function formatLocalStamp(date: Date, timezone: string): string { return date.toLocaleString('sv-SE', { timeZone: resolveTimezone(timezone), year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); } /** * Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock * time in `tz` and return the corresponding UTC Date. Strings that already carry * offset info (`Z` or `+-HH:MM`) are passed through to the Date constructor. */ export function parseZonedToUtc(input: string, tz: string): Date { const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim()); if (hasOffset) return new Date(input); const zone = resolveTimezone(tz); const asIfUtc = new Date(input + 'Z'); if (Number.isNaN(asIfUtc.getTime())) return asIfUtc; const fmt = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( fmt .formatToParts(asIfUtc) .filter((p) => p.type !== 'literal') .map((p) => [p.type, p.value]), ); const hour = parts.hour === '24' ? '00' : parts.hour; const zonedAsUtcMs = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(hour), Number(parts.minute), Number(parts.second), ); const offsetMs = zonedAsUtcMs - asIfUtc.getTime(); return new Date(asIfUtc.getTime() - offsetMs); }