From 53405d7831fd327fe4bfb3bef7d832f878640878 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 21:07:13 +0200 Subject: [PATCH] feat(web): formatTimestamp helper (instance tz + locale, UTC fallback) (#42) --- web/src/lib/format-timestamp.test.ts | 27 +++++++++++++++++++++++++++ web/src/lib/format-timestamp.ts | 18 ++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 web/src/lib/format-timestamp.test.ts create mode 100644 web/src/lib/format-timestamp.ts 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); + } +}