diff --git a/app/client/lib/__tests__/datetime.test.ts b/app/client/lib/__tests__/datetime.test.ts new file mode 100644 index 00000000..4e2475f4 --- /dev/null +++ b/app/client/lib/__tests__/datetime.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { + formatDate, + formatDateTime, + formatDateWithMonth, + formatShortDate, + formatShortDateTime, + formatTime, + formatTimeAgo, +} from "../datetime"; + +afterEach(() => { + mock.restore(); +}); + +const sampleDate = new Date("2026-01-10T14:30:00.000Z"); + +describe("datetime formatters", () => { + test.each([ + formatDateTime, + formatDateWithMonth, + formatDate, + formatShortDate, + formatShortDateTime, + formatTime, + formatTimeAgo, + ])("returns Never when no date is provided", (formatValue) => { + expect(formatValue(null)).toBe("Never"); + }); + + test.each([ + formatDateTime, + formatDateWithMonth, + formatDate, + formatShortDate, + formatShortDateTime, + formatTime, + formatTimeAgo, + ])("returns Invalid Date when the input cannot be parsed", (formatValue) => { + expect(formatValue("not-a-date")).toBe("Invalid Date"); + }); + + test("accepts Date, string, and timestamp inputs for calendar formatters", () => { + const isoDate = sampleDate.toISOString(); + const timestamp = sampleDate.getTime(); + + expect(formatDateTime(isoDate)).toBe(formatDateTime(sampleDate)); + expect(formatDateTime(timestamp)).toBe(formatDateTime(sampleDate)); + expect(formatDateWithMonth(isoDate)).toBe(formatDateWithMonth(sampleDate)); + expect(formatDate(timestamp)).toBe(formatDate(sampleDate)); + expect(formatShortDate(isoDate)).toBe(formatShortDate(sampleDate)); + expect(formatShortDateTime(timestamp)).toBe(formatShortDateTime(sampleDate)); + expect(formatTime(isoDate)).toBe(formatTime(sampleDate)); + }); + + test("formats relative times without approximation prefixes", () => { + const nowSpy = spyOn(Date, "now").mockReturnValue(new Date("2026-01-10T14:35:00.000Z").getTime()); + + expect(formatTimeAgo(sampleDate)).toBe("5 minutes ago"); + + nowSpy.mockRestore(); + }); +}); diff --git a/app/client/lib/datetime.ts b/app/client/lib/datetime.ts index d183dd3f..d9bb7d60 100644 --- a/app/client/lib/datetime.ts +++ b/app/client/lib/datetime.ts @@ -1,93 +1,91 @@ import { formatDistanceToNow, isValid } from "date-fns"; -// 1/10/2026, 2:30 PM -export function formatDateTime(date: Date | string | number | null | undefined): string { - if (!date) return "Never"; - const d = new Date(date); - if (!isValid(d)) return "Invalid Date"; +type DateInput = Date | string | number | null | undefined; - return Intl.DateTimeFormat(navigator.languages, { - month: "numeric", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - }).format(d); +function formatValidDate(date: DateInput, formatter: (date: Date) => string): string { + if (!date) return "Never"; + + const parsedDate = new Date(date); + if (!isValid(parsedDate)) return "Invalid Date"; + + return formatter(parsedDate); +} + +// 1/10/2026, 2:30 PM +export function formatDateTime(date: DateInput): string { + return formatValidDate(date, (validDate) => + Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + }).format(validDate), + ); } // Jan 10, 2026 -export function formatDateWithMonth(date: Date | string | number | null | undefined): string { - if (!date) return "Never"; - const d = new Date(date); - if (!isValid(d)) return "Invalid Date"; - return Intl.DateTimeFormat(navigator.languages, { - month: "short", - day: "numeric", - year: "numeric", - }).format(d); +export function formatDateWithMonth(date: DateInput): string { + return formatValidDate(date, (validDate) => + Intl.DateTimeFormat(navigator.languages, { + month: "short", + day: "numeric", + year: "numeric", + }).format(validDate), + ); } // 1/10/2026 -export function formatDate(date: Date | string | number | null | undefined): string { - if (!date) return "Never"; - const d = new Date(date); - if (!isValid(d)) return "Invalid Date"; - - return Intl.DateTimeFormat(navigator.languages, { - month: "numeric", - day: "numeric", - year: "numeric", - }).format(d); +export function formatDate(date: DateInput): string { + return formatValidDate(date, (validDate) => + Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + year: "numeric", + }).format(validDate), + ); } // 1/10 -export function formatShortDate(date: Date | string | number | null | undefined): string { - if (!date) return "Never"; - const d = new Date(date); - if (!isValid(d)) return "Invalid Date"; - - return Intl.DateTimeFormat(navigator.languages, { - month: "numeric", - day: "numeric", - }).format(d); +export function formatShortDate(date: DateInput): string { + return formatValidDate(date, (validDate) => + Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + }).format(validDate), + ); } // 1/10, 2:30 PM -export function formatShortDateTime(date: Date | string | number | null | undefined): string { - if (!date) return "Never"; - const d = new Date(date); - if (!isValid(d)) return "Invalid Date"; - - return Intl.DateTimeFormat(navigator.languages, { - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - }).format(d); +export function formatShortDateTime(date: DateInput): string { + return formatValidDate(date, (validDate) => + Intl.DateTimeFormat(navigator.languages, { + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }).format(validDate), + ); } // 2:30 PM -export function formatTime(date: Date | string | number | null | undefined): string { - if (!date) return "Never"; - const d = new Date(date); - if (!isValid(d)) return "Invalid Date"; - - return Intl.DateTimeFormat(navigator.languages, { - hour: "numeric", - minute: "numeric", - }).format(d); +export function formatTime(date: DateInput): string { + return formatValidDate(date, (validDate) => + Intl.DateTimeFormat(navigator.languages, { + hour: "numeric", + minute: "numeric", + }).format(validDate), + ); } // 5 minutes ago -export function formatTimeAgo(date: Date | string | number | null | undefined): string { - if (!date) return "Never"; - const d = new Date(date); - if (!isValid(d)) return "Invalid Date"; +export function formatTimeAgo(date: DateInput): string { + return formatValidDate(date, (validDate) => { + const timeAgo = formatDistanceToNow(validDate, { + addSuffix: true, + includeSeconds: true, + }); - const timeAgo = formatDistanceToNow(d, { - addSuffix: true, - includeSeconds: true, + return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", ""); }); - - return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", ""); }