diff --git a/docs/superpowers/specs/2026-06-09-timestamp-tz-design.md b/docs/superpowers/specs/2026-06-09-timestamp-tz-design.md new file mode 100644 index 0000000..65c6824 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-timestamp-tz-design.md @@ -0,0 +1,104 @@ +# 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**: +```ts +/** 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: +```tsx +