Files
tiny-forge/web/src/lib/format/datetime.ts
T
alexei.dolgolyov 03d58a072c
Build / build (push) Successful in 10m35s
fix: treat naive backend timestamps as UTC for relative labels
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.
2026-04-23 14:39:41 +03:00

191 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}