diff --git a/app/client/lib/datetime.ts b/app/client/lib/datetime.ts index e879e213..f2806904 100644 --- a/app/client/lib/datetime.ts +++ b/app/client/lib/datetime.ts @@ -1,204 +1,15 @@ -import { formatDistanceToNow, isValid } from "date-fns"; import { useEffect, useMemo, useState } from "react"; import { useRootLoaderData } from "~/client/hooks/use-root-loader-data"; +import { rawFormatters, type DateInput } from "~/lib/datetime"; -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]; -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 { + DATE_FORMATS, + DEFAULT_TIME_FORMAT, + inferDateTimePreferences, + rawFormatters, + TIME_FORMATS, +} from "~/lib/datetime"; +export type { DateFormatPreference, DateInput, TimeFormatPreference } from "~/lib/datetime"; export function useTimeFormat() { const { locale, timeZone, dateFormat, timeFormat, now } = useRootLoaderData(); @@ -211,24 +22,30 @@ export function useTimeFormat() { 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), + formatDateTime: (date: DateInput) => + rawFormatters.formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }), + formatDateWithMonth: (date: DateInput) => + rawFormatters.formatDateWithMonth(date, { + locale, + timeZone, + dateFormat, + timeFormat, + }), + formatDate: (date: DateInput) => + rawFormatters.formatDate(date, { locale, timeZone, dateFormat, timeFormat }), + formatShortDate: (date: DateInput) => + rawFormatters.formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }), + formatShortDateTime: (date: DateInput) => + rawFormatters.formatShortDateTime(date, { + locale, + timeZone, + dateFormat, + timeFormat, + }), + formatTime: (date: DateInput) => + rawFormatters.formatTime(date, { locale, timeZone, dateFormat, timeFormat }), + formatTimeAgo: (date: DateInput) => rawFormatters.formatTimeAgo(date, currentNow), }), [locale, timeZone, currentNow, dateFormat, timeFormat], ); } - -export const rawFormatters = { - formatDateTime, - formatDateWithMonth, - formatDate, - formatShortDate, - formatShortDateTime, - formatTime, - formatTimeAgo, -}; diff --git a/app/lib/datetime.ts b/app/lib/datetime.ts new file mode 100644 index 00000000..b04ba1bf --- /dev/null +++ b/app/lib/datetime.ts @@ -0,0 +1,210 @@ +import { formatDistanceToNow, isValid } from "date-fns"; + +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]; +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 const rawFormatters = { + formatDateTime, + formatDateWithMonth, + formatDate, + formatShortDate, + formatShortDateTime, + formatTime, + formatTimeAgo, +}; diff --git a/app/server/lib/functions/root-loader-data.ts b/app/server/lib/functions/root-loader-data.ts index 7f788f6f..863fbce2 100644 --- a/app/server/lib/functions/root-loader-data.ts +++ b/app/server/lib/functions/root-loader-data.ts @@ -1,7 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { getCookie, getRequestHeaders } from "@tanstack/react-start/server"; import { THEME_COOKIE_NAME } from "~/client/components/theme-provider"; -import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime"; +import type { DateFormatPreference, TimeFormatPreference } from "~/lib/datetime"; import { getLocaleFromAcceptLanguage } from "~/server/lib/accept-language"; import { auth } from "~/server/lib/auth";