diff --git a/web/src/lib/components/InstanceCard.svelte b/web/src/lib/components/InstanceCard.svelte index bdb2f45..90ac3d6 100644 --- a/web/src/lib/components/InstanceCard.svelte +++ b/web/src/lib/components/InstanceCard.svelte @@ -9,6 +9,7 @@ import ConfirmDialog from './ConfirmDialog.svelte'; import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink, IconEvents } from '$lib/components/icons'; import { t } from '$lib/i18n'; + import { fmt } from '$lib/format/datetime'; import * as api from '$lib/api'; interface Props { @@ -31,17 +32,7 @@ : instance.subdomain ? `https://${instance.subdomain}` : '' ); - const timeSinceCreated = $derived(() => { - const created = new Date(instance.created_at); - const now = new Date(); - const diffMs = now.getTime() - created.getTime(); - const diffMins = Math.floor(diffMs / 60_000); - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; - }); + const timeSinceCreated = $derived(() => $fmt.relative(instance.created_at)); async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') { loading = true; diff --git a/web/src/lib/format/datetime.ts b/web/src/lib/format/datetime.ts index 777cc1a..2be564a 100644 --- a/web/src/lib/format/datetime.ts +++ b/web/src/lib/format/datetime.ts @@ -18,6 +18,23 @@ 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; @@ -28,7 +45,7 @@ function toDate(input: DateInput): Date | null { const d = new Date(ms); return isNaN(d.getTime()) ? null : d; } - const d = new Date(input); + const d = new Date(normalizeIsoUtc(input)); return isNaN(d.getTime()) ? null : d; }