docs(specs): instance-timezone timestamp formatter (#42)
This commit is contained in:
@@ -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
|
||||
<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.
|
||||
Reference in New Issue
Block a user