diff --git a/src/infra/format-time/format-datetime.ts b/src/infra/format-time/format-datetime.ts index d7ed13f5c24..37cdf713f8d 100644 --- a/src/infra/format-time/format-datetime.ts +++ b/src/infra/format-time/format-datetime.ts @@ -59,36 +59,40 @@ export function formatZonedTimestamp( date: Date, options?: FormatZonedTimestampOptions, ): string | undefined { - const intlOptions: Intl.DateTimeFormatOptions = { - timeZone: options?.timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - timeZoneName: "short", - }; - if (options?.displaySeconds) { - intlOptions.second = "2-digit"; - } - const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date); - const pick = (type: string) => parts.find((part) => part.type === type)?.value; - const yyyy = pick("year"); - const mm = pick("month"); - const dd = pick("day"); - const hh = pick("hour"); - const min = pick("minute"); - const sec = options?.displaySeconds ? pick("second") : undefined; - const tz = [...parts] - .toReversed() - .find((part) => part.type === "timeZoneName") - ?.value?.trim(); - if (!yyyy || !mm || !dd || !hh || !min) { + try { + const intlOptions: Intl.DateTimeFormatOptions = { + timeZone: options?.timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }; + if (options?.displaySeconds) { + intlOptions.second = "2-digit"; + } + const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date); + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const sec = options?.displaySeconds ? pick("second") : undefined; + const tz = [...parts] + .toReversed() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + if (!yyyy || !mm || !dd || !hh || !min) { + return undefined; + } + if (options?.displaySeconds && sec) { + return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`; + } + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; + } catch { return undefined; } - if (options?.displaySeconds && sec) { - return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`; - } - return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; } diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index 22ae60dcc6d..42323523f68 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -8,6 +8,12 @@ import { } from "./format-duration.js"; import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js"; +const invalidDurationInputs = [null, undefined, -100] as const; + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("format-duration", () => { describe("formatDurationCompact", () => { it("returns undefined for null/undefined/non-positive", () => { @@ -55,7 +61,7 @@ describe("format-duration", () => { describe("formatDurationHuman", () => { it("returns fallback for invalid duration input", () => { - for (const value of [null, undefined, -100]) { + for (const value of invalidDurationInputs) { expect(formatDurationHuman(value)).toBe("n/a"); } expect(formatDurationHuman(null, "unknown")).toBe("unknown"); @@ -106,6 +112,12 @@ describe("format-duration", () => { it("supports seconds unit", () => { expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds"); }); + + it("clamps negative values and rejects non-finite input", () => { + expect(formatDurationSeconds(-1500, { decimals: 1 })).toBe("0s"); + expect(formatDurationSeconds(NaN)).toBe("unknown"); + expect(formatDurationSeconds(Infinity)).toBe("unknown"); + }); }); }); @@ -152,13 +164,52 @@ describe("format-datetime", () => { const result = formatZonedTimestamp(date, options); expect(result).toMatch(expected); }); + + it("returns undefined when required Intl parts are missing", () => { + function MissingPartsDateTimeFormat() { + return { + formatToParts: () => [ + { type: "month", value: "01" }, + { type: "day", value: "15" }, + { type: "hour", value: "14" }, + { type: "minute", value: "30" }, + ], + } as Intl.DateTimeFormat; + } + + vi.spyOn(Intl, "DateTimeFormat").mockImplementation( + MissingPartsDateTimeFormat as unknown as typeof Intl.DateTimeFormat, + ); + + expect(formatZonedTimestamp(new Date("2024-01-15T14:30:00.000Z"), { timeZone: "UTC" })).toBe( + undefined, + ); + }); + + it("returns undefined when Intl formatting throws", () => { + function ThrowingDateTimeFormat() { + return { + formatToParts: () => { + throw new Error("boom"); + }, + } as Intl.DateTimeFormat; + } + + vi.spyOn(Intl, "DateTimeFormat").mockImplementation( + ThrowingDateTimeFormat as unknown as typeof Intl.DateTimeFormat, + ); + + expect(formatZonedTimestamp(new Date("2024-01-15T14:30:00.000Z"), { timeZone: "UTC" })).toBe( + undefined, + ); + }); }); }); describe("format-relative", () => { describe("formatTimeAgo", () => { it("returns fallback for invalid elapsed input", () => { - for (const value of [null, undefined, -100]) { + for (const value of invalidDurationInputs) { expect(formatTimeAgo(value)).toBe("unknown"); } expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a"); @@ -240,5 +291,14 @@ describe("format-relative", () => { ])("$name", ({ offsetMs, options, expected }) => { expect(formatRelativeTimestamp(Date.now() + offsetMs, options)).toBe(expected); }); + + it("falls back to relative days when date formatting throws", () => { + expect( + formatRelativeTimestamp(Date.now() - 8 * 24 * 3600000, { + dateFallback: true, + timezone: "Invalid/Timezone", + }), + ).toBe("8d ago"); + }); }); });