03d58a072c
Build / build (push) Successful in 10m35s
Backend emits store.Now() as "2026-04-23 14:05:32" — UTC but without a Z marker or T separator. JavaScript parses such strings as local time, so every "N ago" label is skewed by the browser's UTC offset (3h off in UTC+3, causing the visible mismatch with the tooltip). Normalise bare/space-separated timestamps to UTC inside toDate() so every $fmt.* call, including relative, matches the absolute timestamp shown in the tooltip. InstanceCard drops its own parser and delegates to $fmt.relative for consistency.
191 lines
6.3 KiB
TypeScript
191 lines
6.3 KiB
TypeScript
/**
|
||
* 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<typeof unit, string> = { s: 'с', m: 'м', h: 'ч', d: 'д', mo: 'мес' };
|
||
return `${value}${ruUnits[unit]} назад`;
|
||
}
|
||
return `${value}${unit} ago`;
|
||
}
|
||
|
||
export type DateFormatter = ReturnType<typeof makeFormatters>;
|
||
|
||
/**
|
||
* Reactive formatter — re-derives whenever the user changes timezone or
|
||
* locale. Consume with `$fmt.dateTime(...)` in templates.
|
||
*/
|
||
export const fmt: Readable<DateFormatter> = 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);
|
||
}
|