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

8.6 KiB

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): <td className="px-3 py-2 text-muted-foreground">{formatUpdated(object.updated_at)}</td>.
  • 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:
/** 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):
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:
cd web && pnpm vitest run src/lib/format-timestamp.test.ts && pnpm typecheck && pnpm lint

All green.

  • Step 4: Commit
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):
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:

              <td className="px-3 py-2 text-muted-foreground">{formatUpdated(object.updated_at)}</td>

to:

              <td className="px-3 py-2 text-muted-foreground">
                {formatTimestamp(object.updated_at, default_timezone, i18n.language)}
              </td>
  • Step 4: FULL FRONTEND GATE (run tests EXACTLY ONCE):
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:
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

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.