Files
biggus-dickus/docs/superpowers/specs/2026-06-09-timestamp-tz-design.md
T

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 formatTimestamp helper the issue asks for,
  • shows date only (no time-of-day), and
  • has no invalid-IANA guard — a misconfigured default_timezone would make Intl.DateTimeFormat throw a RangeError and 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 }) throws RangeError for a bad zone → the catch re-formats with timeZone: "UTC" (no crash).
  • Edge handling matches format-date.ts: non-string null"—"; 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-string updated_at"—"/String(value) (defensive; in practice updated_at is 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") shows Jun 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".
  • 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:colors green; no new dependency; no new i18n keys; no codename; en/sv parity unaffected.

Acceptance criteria

  1. lib/format-timestamp.ts exports formatTimestamp(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).
  2. objects-table.tsx renders updated_at via formatTimestamp(object.updated_at, default_timezone, i18n.language); the inline dateFmt/formatUpdated are removed; the column shows date + short time.
  3. All existing tests pass (objects-table green); typecheck/lint/build/check:colors green; check:size reported; 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 through formatTimestamp when 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.