/** * Timezone utilities — mirror of src/timezone.ts (host). * * The container can't import from src/ (separate tsconfig, different runtime). * Kept deliberately byte-aligned with the host module so behaviour is the * same on both sides of the session-DB boundary. * * TIMEZONE is resolved once at module load from process.env.TZ (which the host * sets from its own TIMEZONE constant when spawning the container; see * src/container-runner.ts). Invalid values fall back to UTC. */ /** * 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, }); } function resolveContainerTimezone(): string { const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; for (const tz of candidates) { if (tz && isValidTimezone(tz)) return tz; } return 'UTC'; } export const TIMEZONE = resolveContainerTimezone(); /** * 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 * unchanged. * * Algorithm: treat the naive string as UTC, ask Intl.DateTimeFormat what that * UTC instant is called in `tz`, then invert the offset. Near DST boundaries * this can be off by an hour for ~1h of wall-clock time per year; acceptable * for scheduling where the agent normally picks round-hour targets. */ 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); }