5.9 KiB
Instance-Timezone Timestamp Formatter — Design
Date: 2026-06-09 Status: Approved (brainstorming) — ready for implementation planning. Issue: #42 (render UTC timestamps in the instance timezone via Intl — now that a display exists).
Context
#42 was filed conditionally ("wire up the default_timezone formatter when the first timestamp display
lands"). That condition is now met: the objects-table "Updated" column (updated_at, a UTC timestamp)
is rendered — and it's already timezone+locale-aware, but via an inline Intl.DateTimeFormat in
objects-table.tsx (dateStyle: "medium", timeZone: default_timezone) that:
- is not the shared
formatTimestamphelper the issue asks for, - shows date only (no time-of-day), and
- has no invalid-IANA guard — a misconfigured
default_timezonewould makeIntl.DateTimeFormatthrow aRangeErrorand crash the table.
recording_date (object-detail) is a plain DATE formatted by lib/format-date.ts (no timezone) — correct
and out of scope. There are no other UTC-timestamp displays. default_timezone is exposed via
useConfig().default_timezone (IANA name; default "Europe/Stockholm").
This is a display-only change: storage/transmission stay UTC. No backend change, no new dependency, no new i18n keys.
Components
lib/format-timestamp.ts (new)
Mirrors lib/format-date.ts's shape (same null/invalid-string edge handling), for UTC timestamps:
/** Formats a UTC ISO timestamp for display in the instance timezone + active locale.
* Storage/transmission stay UTC — this is display-only. Falls back to UTC formatting on an
* invalid IANA zone (a misconfigured instance) rather than throwing. */
export function formatTimestamp(value: unknown, timeZone: string, locale: string): string {
if (typeof value !== "string") return value == null ? "—" : String(value);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const opts = { dateStyle: "medium", timeStyle: "short" } as const;
try {
return new Intl.DateTimeFormat(locale, { ...opts, timeZone }).format(date);
} catch {
return new Intl.DateTimeFormat(locale, { ...opts, timeZone: "UTC" }).format(date);
}
}
- date + short time (the chosen display) in
timeZone+locale. - Invalid-IANA guard:
new Intl.DateTimeFormat(locale, { timeZone })throwsRangeErrorfor a bad zone → thecatchre-formats withtimeZone: "UTC"(no crash). - Edge handling matches
format-date.ts: non-stringnull→"—"; other non-strings →String(value); an unparseable string → returned unchanged.
objects/objects-table.tsx (modify)
Remove the inline const dateFmt = new Intl.DateTimeFormat(...) + formatUpdated helper. Add
import { formatTimestamp } from "../lib/format-timestamp";. Keep default_timezone (from useConfig())
and i18n.language. Render the Updated cell as:
<td className="px-3 py-2 text-muted-foreground">
{formatTimestamp(object.updated_at, default_timezone, i18n.language)}
</td>
The column changes from date-only to date + short time. (The helper constructs an Intl.DateTimeFormat
per cell rather than once-per-render; negligible for the ≤200-row page — kept simple over re-memoizing.)
Data flow / behaviour
updated_at (UTC ISO from the API) → formatTimestamp(value, default_timezone, i18n.language) → a
locale-formatted date+time in the instance zone. Identical data; only the display string changes (now
includes the time and is crash-guarded).
Error handling / edges
- Invalid
default_timezone→ UTC-formatted output (guarded), never a thrown render. null/non-stringupdated_at→"—"/String(value)(defensive; in practiceupdated_atis always a string).- Unparseable date string → returned verbatim (matches
format-date.ts). - Locale comes from
i18n.language(full-ICU Node in CI / browsers) — deterministic per locale.
Testing
lib/format-timestamp.test.ts(new):- valid:
formatTimestamp("2026-06-08T12:30:00Z", "UTC", "en")contains"2026"and"12:30"(date + time rendered). - timezone applied (day-shift):
formatTimestamp("2026-06-08T02:00:00Z", "America/New_York", "en")showsJun 7(02:00 UTC = 22:00 prev-day EDT), distinct from the same instant in"UTC"(Jun 8) — proves the zone is honored. - invalid IANA:
formatTimestamp("2026-06-08T12:30:00Z", "Not/AZone", "en")does not throw and returns a non-empty string containing"2026"(UTC fallback). null→"—";"not-a-date"→"not-a-date".
- valid:
objects-table.test.tsx: the suite does not assert the rendered Updated value, so it stays green; if any assertion is added/affected, assert the new date+time output loosely (don't pin the exact locale string).- Gate:
typecheck/lint/test/build/check:size/check:colorsgreen; no new dependency; no new i18n keys; no codename; en/sv parity unaffected.
Acceptance criteria
lib/format-timestamp.tsexportsformatTimestamp(value, timeZone, locale)— date+time in the given zone/locale, with a UTC fallback on an invalid IANA zone and the null/invalid edge handling; unit-tested (incl. the day-shift + invalid-zone cases).objects-table.tsxrendersupdated_atviaformatTimestamp(object.updated_at, default_timezone, i18n.language); the inlinedateFmt/formatUpdatedare removed; the column shows date + short time.- All existing tests pass (objects-table green);
typecheck/lint/build/check:colorsgreen;check:sizereported; no new dependency; no new i18n keys; no codename.
Out of scope → follow-ups
- Additional timestamp displays (object-detail
created_at/updated_at, an audit-history view) — none exist yet; route them throughformatTimestampwhen they land. - Server-side timestamp formatting for the PDF export (#39) — needs a Rust tz library, separate.
recording_date/format-date.ts(plain DATE, no timezone) — unchanged.