/** * Timezone-aware date/time formatting. * * Every call site in the app should format through this module — the point of * the user-selected timezone is defeated if any page slips back to the raw * `toLocaleString()` which silently uses the browser zone. * * All functions accept the same input shapes: an ISO string, a `Date`, or a * unix timestamp (seconds since epoch — matches Docker API). Falsy inputs * return an em dash placeholder so callers don't need a guard. */ import { derived, get, type Readable } from 'svelte/store'; import { effectiveTimezone } from '$lib/stores/timezone'; import { locale } from '$lib/i18n'; export type DateInput = string | number | Date | null | undefined; const PLACEHOLDER = '—'; /** * Backend emits `store.Now()` as `"2006-01-02 15:04:05"` (UTC, but no `Z`). * Bare ISO strings without a zone (e.g. `"2026-04-23T14:05:32"`) show up too. * JavaScript interprets both as **local** time, which silently skews every * relative label ("3h ago") by the user's UTC offset. Normalise anything * that lacks an explicit zone marker to UTC before constructing the Date. */ function normalizeIsoUtc(input: string): string { const trimmed = input.trim(); // Already has Z or ±HH:MM / ±HHMM offset → trust it. if (/(Z|[+-]\d{2}:?\d{2})$/.test(trimmed)) return trimmed; // SQLite-style "YYYY-MM-DD HH:MM:SS(.ffff)?" — swap space for T, append Z. const sqliteMatch = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/); if (sqliteMatch) return `${sqliteMatch[1]}T${sqliteMatch[2]}Z`; return trimmed; } function toDate(input: DateInput): Date | null { if (input === null || input === undefined || input === '') return null; if (input instanceof Date) return isNaN(input.getTime()) ? null : input; if (typeof input === 'number') { // Docker timestamps come in as unix seconds; JS Date wants ms. // Values below 1e12 are plausibly seconds, above are ms. const ms = input < 1e12 ? input * 1000 : input; const d = new Date(ms); return isNaN(d.getTime()) ? null : d; } const d = new Date(normalizeIsoUtc(input)); return isNaN(d.getTime()) ? null : d; } function localeTag(loc: string): string { // Map our app locale codes to BCP-47 tags. Unknown codes fall back to en-GB // which gives an ISO-like day-month order — less confusing internationally // than US en-US month-first. switch (loc) { case 'ru': return 'ru-RU'; case 'en': return 'en-GB'; default: return loc || 'en-GB'; } } function makeFormatters(tz: string, loc: string) { const tag = localeTag(loc); const baseOpts: Intl.DateTimeFormatOptions = { timeZone: tz }; // Instantiate once per (tz, locale) pair — Intl.DateTimeFormat construction // is surprisingly expensive when called inside tight loops (event log list). const dateTimeFmt = new Intl.DateTimeFormat(tag, { ...baseOpts, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const dateFmt = new Intl.DateTimeFormat(tag, { ...baseOpts, year: 'numeric', month: 'short', day: '2-digit' }); const timeFmt = new Intl.DateTimeFormat(tag, { ...baseOpts, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const shortDateFmt = new Intl.DateTimeFormat(tag, { ...baseOpts, month: 'short', day: 'numeric', year: 'numeric' }); const compactFmt = new Intl.DateTimeFormat(tag, { ...baseOpts, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }); const clockFmt = new Intl.DateTimeFormat(tag, { ...baseOpts, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); return { /** Full timestamp: "23 Apr 2026, 14:05:32". Use for event log detail, logs. */ dateTime(input: DateInput): string { const d = toDate(input); return d ? dateTimeFmt.format(d) : PLACEHOLDER; }, /** Date only: "23 Apr 2026". Use for created_at columns. */ date(input: DateInput): string { const d = toDate(input); return d ? dateFmt.format(d) : PLACEHOLDER; }, /** Clock time: "14:05:32". Use for live headers and the settings preview. */ time(input: DateInput): string { const d = toDate(input); return d ? timeFmt.format(d) : PLACEHOLDER; }, /** "Apr 23, 2026" — matches legacy StaleContainerCard look. */ shortDate(input: DateInput): string { const d = toDate(input); return d ? shortDateFmt.format(d) : PLACEHOLDER; }, /** Compact: "Apr 23, 14:05". Use in dense tables. */ compact(input: DateInput): string { const d = toDate(input); return d ? compactFmt.format(d) : PLACEHOLDER; }, /** Clock HH:MM:SS — used by the live clock in the timezone card. */ clock(input: DateInput): string { const d = toDate(input); return d ? clockFmt.format(d) : PLACEHOLDER; }, /** Relative "5m ago" — timezone-independent but locale-aware. */ relative(input: DateInput): string { const d = toDate(input); if (!d) return PLACEHOLDER; const diffSec = Math.floor((Date.now() - d.getTime()) / 1000); if (diffSec < 60) return relativeLabel(loc, diffSec, 's'); const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return relativeLabel(loc, diffMin, 'm'); const diffHour = Math.floor(diffMin / 60); if (diffHour < 24) return relativeLabel(loc, diffHour, 'h'); const diffDay = Math.floor(diffHour / 24); if (diffDay < 30) return relativeLabel(loc, diffDay, 'd'); return relativeLabel(loc, Math.floor(diffDay / 30), 'mo'); }, /** Currently active zone — exposed so UIs can show "rendered in X". */ timezone: tz, locale: tag }; } function relativeLabel(loc: string, value: number, unit: 's' | 'm' | 'h' | 'd' | 'mo'): string { if (loc === 'ru') { const ruUnits: Record = { s: 'с', m: 'м', h: 'ч', d: 'д', mo: 'мес' }; return `${value}${ruUnits[unit]} назад`; } return `${value}${unit} ago`; } export type DateFormatter = ReturnType; /** * Reactive formatter — re-derives whenever the user changes timezone or * locale. Consume with `$fmt.dateTime(...)` in templates. */ export const fmt: Readable = derived( [effectiveTimezone, locale], ([$tz, $loc]) => makeFormatters($tz, $loc) ); /** One-shot formatter snapshot for imperative code (event handlers, tooltips). */ export function currentFormatter(): DateFormatter { return get(fmt); }