diff --git a/docs/superpowers/plans/2026-06-09-timestamp-tz.md b/docs/superpowers/plans/2026-06-09-timestamp-tz.md
new file mode 100644
index 0000000..f2891c2
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-09-timestamp-tz.md
@@ -0,0 +1,154 @@
+# Instance-Timezone Timestamp Formatter — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a shared `formatTimestamp(value, timeZone, locale)` helper (date+time in the instance timezone, with an invalid-IANA → UTC fallback) and route the objects-table "Updated" column through it.
+
+**Architecture:** Task 1 adds `lib/format-timestamp.ts` (mirrors `lib/format-date.ts`) + its unit test. Task 2 swaps objects-table's inline `dateFmt`/`formatUpdated` for the helper and runs the full gate. Display-only; UTC stays in storage/transmission.
+
+**Tech Stack:** React 19 + TS + pnpm, `Intl.DateTimeFormat`, Vitest 4 (jsdom).
+
+**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon. No new dependency, no new i18n keys, no backend change.
+
+**Spec:** `docs/superpowers/specs/2026-06-09-timestamp-tz-design.md`
+
+**Key facts:**
+- `lib/format-date.ts` is the sibling pattern (date-only, no tz): `if (typeof value !== "string") return value == null ? "—" : String(value); const date = new Date(\`${value}T00:00:00\`); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat(lang, { dateStyle: "medium" }).format(date);`.
+- `objects/objects-table.tsx`: `const { t, i18n } = useTranslation();` (line ~31), `const { default_timezone } = useConfig();` (~32). Lines ~123-131 are the inline `const dateFmt = new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeZone: default_timezone });` + `const formatUpdated = (iso: string) => { const parsed = new Date(iso); return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed); };`. The Updated cell (~line 270): `
{formatUpdated(object.updated_at)} | `.
+- After removing `dateFmt`/`formatUpdated`, both `i18n` and `default_timezone` remain used (passed to `formatTimestamp`), and `t` is used elsewhere — no unused-locals.
+- `objects-table.test.tsx` does NOT assert the rendered Updated value → no test edit needed there. Fixture `amphora.updated_at = "2026-01-05T14:30:00Z"`.
+
+---
+
+# Task 1: `lib/format-timestamp.ts` + test
+
+**Files:** Create `web/src/lib/format-timestamp.ts`, `web/src/lib/format-timestamp.test.ts`.
+
+- [ ] **Step 1: Create `web/src/lib/format-timestamp.ts`:**
+```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 {
+ // Invalid IANA timeZone (misconfigured instance) — fall back to UTC rather than crash.
+ return new Intl.DateTimeFormat(locale, { ...opts, timeZone: "UTC" }).format(date);
+ }
+}
+```
+
+- [ ] **Step 2: Create `web/src/lib/format-timestamp.test.ts`** (write + run):
+```ts
+import { expect, test } from "vitest";
+
+import { formatTimestamp } from "./format-timestamp";
+
+test("formats a UTC timestamp with date and time in the given locale", () => {
+ const out = formatTimestamp("2026-06-08T12:30:00Z", "UTC", "en");
+ expect(out).toContain("2026");
+ expect(out).toContain("12:30");
+});
+
+test("applies the timezone — a near-midnight UTC instant shifts the calendar day", () => {
+ // 02:00 UTC on Jun 8 is 22:00 on Jun 7 in New York (EDT, UTC-4)
+ const ny = formatTimestamp("2026-06-08T02:00:00Z", "America/New_York", "en");
+ const utc = formatTimestamp("2026-06-08T02:00:00Z", "UTC", "en");
+ expect(ny).toContain("Jun 7");
+ expect(utc).toContain("Jun 8");
+});
+
+test("an invalid IANA zone does not throw and falls back to UTC", () => {
+ const out = formatTimestamp("2026-06-08T12:30:00Z", "Not/AZone", "en");
+ expect(out).toContain("2026");
+});
+
+test("null renders the em-dash placeholder; an unparseable string is returned unchanged", () => {
+ expect(formatTimestamp(null, "UTC", "en")).toBe("—");
+ expect(formatTimestamp("not-a-date", "UTC", "en")).toBe("not-a-date");
+});
+```
+Run: `cd web && pnpm vitest run src/lib/format-timestamp.test.ts` → 4 passing. (Full-ICU Node renders en medium+short as e.g. `"Jun 8, 2026, 12:30 PM"`; the assertions check substrings — `2026`, `12:30`, `Jun 7`/`Jun 8` — to stay robust across ICU punctuation. If the local ICU renders the time without a leading-zero/`12:30`, assert the day-shift `Jun 7` vs `Jun 8` which is the load-bearing tz check.)
+
+- [ ] **Step 3: Verify + lint:**
+```bash
+cd web && pnpm vitest run src/lib/format-timestamp.test.ts && pnpm typecheck && pnpm lint
+```
+All green.
+
+- [ ] **Step 4: Commit**
+```bash
+cd /Users/olsson/Laboratory/biggus-dickus
+git add web/src/lib/format-timestamp.ts web/src/lib/format-timestamp.test.ts
+git commit -m "feat(web): formatTimestamp helper (instance tz + locale, UTC fallback) (#42)"
+```
+
+---
+
+# Task 2: Route objects-table "Updated" through `formatTimestamp` + full gate
+
+**Files:** Modify `web/src/objects/objects-table.tsx`.
+
+- [ ] **Step 1: Add the import** to `web/src/objects/objects-table.tsx` (alongside the other `../lib/*` imports):
+```ts
+import { formatTimestamp } from "../lib/format-timestamp";
+```
+
+- [ ] **Step 2: Remove the inline formatter.** Delete the `const dateFmt = new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeZone: default_timezone });` block AND the `const formatUpdated = (iso: string) => { … };` function (the ~9 lines, currently around lines 123-131). (`i18n` and `default_timezone` stay declared — they're now passed to `formatTimestamp` at the call site; `t` remains used elsewhere.)
+
+- [ ] **Step 3: Update the Updated cell.** Change:
+```tsx
+ {formatUpdated(object.updated_at)} |
+```
+to:
+```tsx
+
+ {formatTimestamp(object.updated_at, default_timezone, i18n.language)}
+ |
+```
+
+- [ ] **Step 4: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
+```bash
+cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
+```
+All green. The objects-table tests stay green (they don't assert the Updated cell's text). Report total test count, largest chunk (gz), the check:colors line. If typecheck flags `i18n`/`default_timezone` as unused, the call site in Step 3 must reference them (it does) — re-check the edit.
+
+- [ ] **Step 5: Codename + status:**
+```bash
+cd /Users/olsson/Laboratory/biggus-dickus
+git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
+git status --short
+```
+Expected: no matches (`codename-exit=1`).
+
+- [ ] **Step 6: Manual smoke (recommended).** `pnpm dev`: the objects list "Updated" column now shows date + time in the instance timezone (e.g. for an instance in `Europe/Stockholm`, a `…T14:30:00Z` value renders ~`Jan 5, 2026, 3:30 PM`); switching the UI language reformats it.
+
+- [ ] **Step 7: Commit**
+```bash
+cd /Users/olsson/Laboratory/biggus-dickus
+git add web/src/objects/objects-table.tsx
+git commit -m "feat(web): render objects 'Updated' as a tz-aware timestamp via formatTimestamp (#42)"
+```
+
+---
+
+## Self-Review (completed)
+
+**Spec coverage:** AC1 `formatTimestamp` helper + unit tests incl. day-shift + invalid-zone (T1); AC2 objects-table adoption, inline formatter removed, date+time (T2 S1-S3); AC3 gate/codename/no-new-dep-or-keys (T2 S4-S5). ✓
+
+**Placeholder scan:** full helper + test code; the ICU-substring note gives a concrete robustness fallback (assert `Jun 7`/`Jun 8`); exact lines/strings for the objects-table edit. No TBD. ✓
+
+**Type/consistency:** `formatTimestamp(value: unknown, timeZone: string, locale: string)` (T1) called as `formatTimestamp(object.updated_at, default_timezone, i18n.language)` (T2) — `updated_at: string`, `default_timezone: string` (useConfig), `i18n.language: string`. ✓
+
+## Notes
+- No new dependency, no new i18n keys, no backend change. `format-date.ts` (plain DATE) is untouched.
+- The helper constructs an `Intl.DateTimeFormat` per call (vs the prior once-per-render memo); negligible for the ≤200-row page.
+- Only the one timestamp display exists today; future displays (object-detail created/updated, audit history) route through the same helper when they land.
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
+
+ {formatTimestamp(object.updated_at, default_timezone, i18n.language)}
+ |
+```
+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.
diff --git a/web/src/lib/format-timestamp.test.ts b/web/src/lib/format-timestamp.test.ts
new file mode 100644
index 0000000..a940430
--- /dev/null
+++ b/web/src/lib/format-timestamp.test.ts
@@ -0,0 +1,27 @@
+import { expect, test } from "vitest";
+
+import { formatTimestamp } from "./format-timestamp";
+
+test("formats a UTC timestamp with date and time in the given locale", () => {
+ const out = formatTimestamp("2026-06-08T12:30:00Z", "UTC", "en");
+ expect(out).toContain("2026");
+ expect(out).toContain("12:30");
+});
+
+test("applies the timezone — a near-midnight UTC instant shifts the calendar day", () => {
+ // 02:00 UTC on Jun 8 is 22:00 on Jun 7 in New York (EDT, UTC-4)
+ const ny = formatTimestamp("2026-06-08T02:00:00Z", "America/New_York", "en");
+ const utc = formatTimestamp("2026-06-08T02:00:00Z", "UTC", "en");
+ expect(ny).toContain("Jun 7");
+ expect(utc).toContain("Jun 8");
+});
+
+test("an invalid IANA zone does not throw and falls back to UTC", () => {
+ const out = formatTimestamp("2026-06-08T12:30:00Z", "Not/AZone", "en");
+ expect(out).toContain("2026");
+});
+
+test("null renders the em-dash placeholder; an unparseable string is returned unchanged", () => {
+ expect(formatTimestamp(null, "UTC", "en")).toBe("—");
+ expect(formatTimestamp("not-a-date", "UTC", "en")).toBe("not-a-date");
+});
diff --git a/web/src/lib/format-timestamp.ts b/web/src/lib/format-timestamp.ts
new file mode 100644
index 0000000..6adfbac
--- /dev/null
+++ b/web/src/lib/format-timestamp.ts
@@ -0,0 +1,18 @@
+/** 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 {
+ // Invalid IANA timeZone (misconfigured instance) — fall back to UTC rather than crash.
+ return new Intl.DateTimeFormat(locale, { ...opts, timeZone: "UTC" }).format(date);
+ }
+}
diff --git a/web/src/objects/objects-table.tsx b/web/src/objects/objects-table.tsx
index 046c33f..8073e32 100644
--- a/web/src/objects/objects-table.tsx
+++ b/web/src/objects/objects-table.tsx
@@ -8,6 +8,7 @@ import { useObjectsPage } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { focusRing } from "../lib/focus-ring";
import { segmentClass, rowStateClass } from "../lib/class-recipes";
+import { formatTimestamp } from "../lib/format-timestamp";
import { useConfig } from "../config/config-context";
import { VisibilityBadge } from "./visibility-badge";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -120,16 +121,6 @@ export function ObjectsTable() {
else next.set("offset", String(value));
});
- const dateFmt = new Intl.DateTimeFormat(i18n.language, {
- dateStyle: "medium",
- timeZone: default_timezone,
- });
- const formatUpdated = (iso: string) => {
- const parsed = new Date(iso);
-
- return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
- };
-
const headerCell = (col: SortColumn) => {
const active = sort === col;
const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none";
@@ -267,7 +258,9 @@ export function ObjectsTable() {
{object.current_location ?? "—"} |
{object.number_of_objects} |
- {formatUpdated(object.updated_at)} |
+
+ {formatTimestamp(object.updated_at, default_timezone, i18n.language)}
+ |
);
})}