docs(plans): instance-timezone timestamp formatter — 2-task plan (#42)
This commit is contained in:
@@ -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): `<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`:**
|
||||||
|
```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
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{formatUpdated(object.updated_at)}</td>
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```tsx
|
||||||
|
<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):**
|
||||||
|
```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.
|
||||||
Reference in New Issue
Block a user