export const DEFAULT_TIME_ZONE = 'America/New_York'; export function validTimeZone(value: unknown): value is string { if (typeof value !== 'string' || !value.trim()) return false; try { new Intl.DateTimeFormat('en-US', { timeZone: value }); return true; } catch { return false; } } export function availableTimeZones(): string[] { // Older browsers can still select common zones when enumeration is unavailable. const fallback = [DEFAULT_TIME_ZONE, 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu', 'Europe/London', 'Europe/Paris', 'Asia/Kolkata', 'Asia/Tokyo', 'Australia/Sydney']; const zones = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : fallback; return [...new Set([DEFAULT_TIME_ZONE, 'UTC', ...zones])].sort(); } export type Timestamp = string | number | Date | null | undefined; /** Database timestamps without an offset are UTC, never browser-local time. */ function parseTimestamp(value: Exclude): Date { if (value instanceof Date) return value; if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(value)) { return new Date(value.replace(' ', 'T') + 'Z'); } return new Date(value); } export function timestampISO(value: Timestamp): string | undefined { if (value === null || value === undefined || value === '') return undefined; const parsed = parseTimestamp(value); return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined; } export function createTimeFormatter(zone: string) { const timeZone = validTimeZone(zone) ? zone : DEFAULT_TIME_ZONE; const date: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }; const time: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit', second: '2-digit', timeZoneName: 'short' }; function formatter(options: Intl.DateTimeFormatOptions) { const format = new Intl.DateTimeFormat('en-US', { ...options, timeZone }); return (value: Timestamp): string => { if (value === null || value === undefined || value === '') return '—'; const parsed = parseTimestamp(value); return Number.isFinite(parsed.getTime()) ? format.format(parsed) : '—'; }; } const absolute = formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }); const clock = formatter({ hour: 'numeric', minute: '2-digit' }); const calendar = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }); const calendarDay = (value: Date) => { const parts = Object.fromEntries(calendar.formatToParts(value).map(part => [part.type, part.value])); return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day)); }; function relative(value: Timestamp, now = Date.now()): string { if (value === null || value === undefined || value === '') return '—'; const parsed = parseTimestamp(value); if (!Number.isFinite(parsed.getTime()) || !Number.isFinite(now)) return '—'; const age = now - parsed.getTime(); if (age < 0) return absolute(parsed); if (age < 5_000) return 'Just now'; if (age < 60_000) return `${Math.floor(age / 1000)} s ago`; if (age < 3_600_000) return `${Math.floor(age / 60_000)} min ago`; if (age < 86_400_000) return `${Math.floor(age / 3_600_000)} h ago`; // Calendar arithmetic keeps Yesterday correct across DST and year changes. if (calendarDay(new Date(now)) - calendarDay(parsed) === 86_400_000) return `Yesterday, ${clock(parsed)}`; return absolute(parsed); } return { relative, dateTime: formatter({ ...date, ...time }), date: formatter(date), time: formatter(time), clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }), minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }), period: absolute }; }