refactor(datetime): formatting helpers
This commit is contained in:
parent
5f7f2005fa
commit
959cb21d83
2 changed files with 130 additions and 69 deletions
63
app/client/lib/__tests__/datetime.test.ts
Normal file
63
app/client/lib/__tests__/datetime.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,93 +1,91 @@
|
||||||
import { formatDistanceToNow, isValid } from "date-fns";
|
import { formatDistanceToNow, isValid } from "date-fns";
|
||||||
|
|
||||||
// 1/10/2026, 2:30 PM
|
type DateInput = Date | string | number | null | undefined;
|
||||||
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";
|
|
||||||
|
|
||||||
return Intl.DateTimeFormat(navigator.languages, {
|
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||||
month: "numeric",
|
if (!date) return "Never";
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
const parsedDate = new Date(date);
|
||||||
hour: "numeric",
|
if (!isValid(parsedDate)) return "Invalid Date";
|
||||||
minute: "numeric",
|
|
||||||
}).format(d);
|
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
|
// Jan 10, 2026
|
||||||
export function formatDateWithMonth(date: Date | string | number | null | undefined): string {
|
export function formatDateWithMonth(date: DateInput): string {
|
||||||
if (!date) return "Never";
|
return formatValidDate(date, (validDate) =>
|
||||||
const d = new Date(date);
|
Intl.DateTimeFormat(navigator.languages, {
|
||||||
if (!isValid(d)) return "Invalid Date";
|
month: "short",
|
||||||
return Intl.DateTimeFormat(navigator.languages, {
|
day: "numeric",
|
||||||
month: "short",
|
year: "numeric",
|
||||||
day: "numeric",
|
}).format(validDate),
|
||||||
year: "numeric",
|
);
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10/2026
|
// 1/10/2026
|
||||||
export function formatDate(date: Date | string | number | null | undefined): string {
|
export function formatDate(date: DateInput): string {
|
||||||
if (!date) return "Never";
|
return formatValidDate(date, (validDate) =>
|
||||||
const d = new Date(date);
|
Intl.DateTimeFormat(navigator.languages, {
|
||||||
if (!isValid(d)) return "Invalid Date";
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
return Intl.DateTimeFormat(navigator.languages, {
|
year: "numeric",
|
||||||
month: "numeric",
|
}).format(validDate),
|
||||||
day: "numeric",
|
);
|
||||||
year: "numeric",
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10
|
// 1/10
|
||||||
export function formatShortDate(date: Date | string | number | null | undefined): string {
|
export function formatShortDate(date: DateInput): string {
|
||||||
if (!date) return "Never";
|
return formatValidDate(date, (validDate) =>
|
||||||
const d = new Date(date);
|
Intl.DateTimeFormat(navigator.languages, {
|
||||||
if (!isValid(d)) return "Invalid Date";
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
return Intl.DateTimeFormat(navigator.languages, {
|
}).format(validDate),
|
||||||
month: "numeric",
|
);
|
||||||
day: "numeric",
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10, 2:30 PM
|
// 1/10, 2:30 PM
|
||||||
export function formatShortDateTime(date: Date | string | number | null | undefined): string {
|
export function formatShortDateTime(date: DateInput): string {
|
||||||
if (!date) return "Never";
|
return formatValidDate(date, (validDate) =>
|
||||||
const d = new Date(date);
|
Intl.DateTimeFormat(navigator.languages, {
|
||||||
if (!isValid(d)) return "Invalid Date";
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
return Intl.DateTimeFormat(navigator.languages, {
|
hour: "numeric",
|
||||||
month: "numeric",
|
minute: "numeric",
|
||||||
day: "numeric",
|
}).format(validDate),
|
||||||
hour: "numeric",
|
);
|
||||||
minute: "numeric",
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2:30 PM
|
// 2:30 PM
|
||||||
export function formatTime(date: Date | string | number | null | undefined): string {
|
export function formatTime(date: DateInput): string {
|
||||||
if (!date) return "Never";
|
return formatValidDate(date, (validDate) =>
|
||||||
const d = new Date(date);
|
Intl.DateTimeFormat(navigator.languages, {
|
||||||
if (!isValid(d)) return "Invalid Date";
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
return Intl.DateTimeFormat(navigator.languages, {
|
}).format(validDate),
|
||||||
hour: "numeric",
|
);
|
||||||
minute: "numeric",
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5 minutes ago
|
// 5 minutes ago
|
||||||
export function formatTimeAgo(date: Date | string | number | null | undefined): string {
|
export function formatTimeAgo(date: DateInput): string {
|
||||||
if (!date) return "Never";
|
return formatValidDate(date, (validDate) => {
|
||||||
const d = new Date(date);
|
const timeAgo = formatDistanceToNow(validDate, {
|
||||||
if (!isValid(d)) return "Invalid Date";
|
addSuffix: true,
|
||||||
|
includeSeconds: true,
|
||||||
|
});
|
||||||
|
|
||||||
const timeAgo = formatDistanceToNow(d, {
|
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
||||||
addSuffix: true,
|
|
||||||
includeSeconds: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue