import { formatDistanceToNow, isValid } from "date-fns"; import { useEffect, useMemo, useState } from "react"; import { useRootLoaderData } from "~/client/hooks/use-root-loader-data"; export type DateInput = Date | string | number | null | undefined; export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const; export type DateFormatPreference = (typeof DATE_FORMATS)[number]; export const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY"; export const TIME_FORMATS = ["12h", "24h"] as const; export type TimeFormatPreference = (typeof TIME_FORMATS)[number]; export const DEFAULT_TIME_FORMAT: TimeFormatPreference = "12h"; const BROWSER_PREFERENCE_SAMPLE_DATE = new Date(Date.UTC(2006, 0, 2, 15, 4, 5)); const DATE_PART_ORDERS = { "MM/DD/YYYY": ["month", "day", "year"], "DD/MM/YYYY": ["day", "month", "year"], "YYYY/MM/DD": ["year", "month", "day"], } as const; const SHORT_DATE_PART_ORDERS = { "MM/DD/YYYY": ["month", "day"], "DD/MM/YYYY": ["day", "month"], "YYYY/MM/DD": ["month", "day"], } as const; type DateFormatOptions = { locale?: string | string[]; timeZone?: string; dateFormat?: DateFormatPreference; timeFormat?: TimeFormatPreference; }; 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); } function getDateTimeFormat( locale: DateFormatOptions["locale"], timeZone: DateFormatOptions["timeZone"], options: Intl.DateTimeFormatOptions, ) { return Intl.DateTimeFormat(locale, { ...options, timeZone, }); } function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes) { const value = parts.find((part) => part.type === type)?.value; if (!value) { throw new Error(`Missing ${type} in formatted date`); } return value; } function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) { const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT; const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT; const parts = getDateTimeFormat(options.locale, options.timeZone, { month: "2-digit", day: "2-digit", year: "numeric", }).formatToParts(date); const values = { month: getRequiredPart(parts, "month"), day: getRequiredPart(parts, "day"), year: getRequiredPart(parts, "year"), }; const order = includeYear ? DATE_PART_ORDERS[safeDateFormat] : SHORT_DATE_PART_ORDERS[safeDateFormat]; return order.map((part) => values[part]).join("/"); } function formatConfiguredDateWithMonth(date: Date, options: DateFormatOptions) { const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT; const parts = getDateTimeFormat(options.locale, options.timeZone, { month: "short", day: "numeric", year: "numeric", }).formatToParts(date); const month = getRequiredPart(parts, "month"); const day = getRequiredPart(parts, "day"); const year = getRequiredPart(parts, "year"); if (dateFormat === "DD/MM/YYYY") { return `${day} ${month} ${year}`; } if (dateFormat === "YYYY/MM/DD") { return `${year} ${month} ${day}`; } return `${month} ${day}, ${year}`; } function formatConfiguredTime(date: Date, options: DateFormatOptions) { return getDateTimeFormat(options.locale, options.timeZone, { hour: "numeric", minute: "numeric", hour12: (options.timeFormat ?? DEFAULT_TIME_FORMAT) === "12h", }).format(date); } export function inferDateTimePreferences(locale?: string) { const dateOrder = getDateTimeFormat(locale, undefined, { month: "numeric", day: "numeric", year: "numeric", }) .formatToParts(BROWSER_PREFERENCE_SAMPLE_DATE) .flatMap((part) => { if (part.type === "year" || part.type === "month" || part.type === "day") { return [part.type]; } return []; }) .join("/"); let dateFormat = DEFAULT_DATE_FORMAT; if (dateOrder === "day/month/year") { dateFormat = "DD/MM/YYYY"; } else if (dateOrder === "year/month/day") { dateFormat = "YYYY/MM/DD"; } let timeFormat: TimeFormatPreference = "12h"; const hour12 = getDateTimeFormat(locale, undefined, { hour: "numeric" }).resolvedOptions().hour12; if (hour12 === false) { timeFormat = "24h"; } return { dateFormat, timeFormat, }; } // 01/10/2026, 2:30 PM function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string { return formatValidDate( date, (validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`, ); } // Jan 10, 2026 function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string { return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options)); } // 01/10/2026 function formatDate(date: DateInput, options: DateFormatOptions = {}): string { return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true)); } // 1/10 function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string { return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false)); } // 01/10, 2:30 PM function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string { return formatValidDate( date, (validDate) => `${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`, ); } // 2:30 PM function formatTime(date: DateInput, options: DateFormatOptions = {}): string { return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options)); } // 5 minutes ago function formatTimeAgo(date: DateInput, now = Date.now()): string { return formatValidDate(date, (validDate) => { if (Math.abs(now - validDate.getTime()) < 120_000) { return "just now"; } const timeAgo = formatDistanceToNow(validDate, { addSuffix: true, includeSeconds: true, }); return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", ""); }); } export function useTimeFormat() { const { locale, timeZone, dateFormat, timeFormat, now } = useRootLoaderData(); const [currentNow, setCurrentNow] = useState(now); useEffect(() => { const nextNow = Date.now(); setCurrentNow(nextNow === now ? now : nextNow); }, [now]); return useMemo( () => ({ formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }), formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone, dateFormat, timeFormat }), formatDate: (date: DateInput) => formatDate(date, { locale, timeZone, dateFormat, timeFormat }), formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }), formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone, dateFormat, timeFormat }), formatTime: (date: DateInput) => formatTime(date, { locale, timeZone, dateFormat, timeFormat }), formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow), }), [locale, timeZone, currentNow, dateFormat, timeFormat], ); } export const rawFormatters = { formatDateTime, formatDateWithMonth, formatDate, formatShortDate, formatShortDateTime, formatTime, formatTimeAgo, };