Last active
September 11, 2025 21:12
-
-
Save alexcarpenter/40cd14a382683f6b3ed4047e892495ec to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| format as dateFnsFormat, | |
| formatDistanceToNow, | |
| isToday, | |
| isValid, | |
| isYesterday, | |
| parseISO, | |
| } from 'date-fns'; | |
| /** | |
| * Standardized date formatting for the entire application. | |
| */ | |
| export type DateFormatOptions = | |
| | { | |
| style: 'display'; | |
| relative?: boolean | number; | |
| month?: 'short' | 'long'; | |
| year?: boolean | 'auto'; | |
| day?: boolean | 'ordinal'; | |
| time?: boolean | '12h'; | |
| timeSeparator?: string; | |
| } | |
| | { | |
| style: 'iso'; | |
| } | |
| | { | |
| style: 'time'; | |
| format?: '24h' | '12h' | '24h-seconds' | '12h-seconds'; | |
| }; | |
| type DateInput = Date | number | string; | |
| const DEFAULT_RELATIVE_THRESHOLD_HOURS = 48; | |
| const TIME_FORMATS = { | |
| '24h': 'HH:mm', | |
| '12h': 'h:mm a', | |
| '24h-seconds': 'HH:mm:ss', | |
| '12h-seconds': 'h:mm:ss a', | |
| } as const; | |
| /** | |
| * Safely parse a date input into a Date object | |
| */ | |
| function parseDate(date: DateInput): Date { | |
| const dateObj = typeof date === 'string' ? parseISO(date) : new Date(date); | |
| if (!isValid(dateObj)) { | |
| throw new Error(`Invalid date provided: ${date}`); | |
| } | |
| return dateObj; | |
| } | |
| /** | |
| * Build format string for display style | |
| */ | |
| function buildDisplayFormat( | |
| options: Extract<DateFormatOptions, { style: 'display' }>, | |
| ): string { | |
| const monthFormat = options.month === 'long' ? 'MMMM' : 'MMM'; | |
| const showDay = options.day !== false; | |
| const ordinalDay = options.day === 'ordinal'; | |
| let formatString = ''; | |
| if (showDay) { | |
| const dayFormat = ordinalDay ? 'do' : 'd'; | |
| formatString = `${monthFormat} ${dayFormat}`; | |
| } else { | |
| formatString = monthFormat; | |
| } | |
| const shouldShowYear = | |
| options.year === true || | |
| (options.year !== false && options.year !== 'auto') || | |
| (options.year === 'auto' && | |
| new Date().getFullYear() !== new Date().getFullYear()); | |
| if (shouldShowYear) { | |
| formatString += ', yyyy'; | |
| } | |
| if (options.time) { | |
| const timeFormat = options.time === '12h' ? 'h:mm a' : 'HH:mm'; | |
| const separator = options.timeSeparator || 'at'; | |
| formatString += ` '${separator}' ${timeFormat}`; | |
| } | |
| return formatString; | |
| } | |
| /** | |
| * Handle relative date formatting | |
| */ | |
| function formatRelativeDate( | |
| dateObj: Date, | |
| options: Extract<DateFormatOptions, { style: 'display' }>, | |
| relative: boolean | number, | |
| ): string | null { | |
| const threshold = | |
| typeof relative === 'number' ? relative : DEFAULT_RELATIVE_THRESHOLD_HOURS; | |
| const hoursAgo = (Date.now() - dateObj.getTime()) / (1000 * 60 * 60); | |
| if (hoursAgo >= threshold) { | |
| return null; // Use regular formatting | |
| } | |
| if (isToday(dateObj)) { | |
| if (options.time) { | |
| const timeFormat = options.time === '12h' ? 'h:mm a' : 'HH:mm'; | |
| return `today at ${dateFnsFormat(dateObj, timeFormat)}`; | |
| } | |
| return 'today'; | |
| } | |
| if (isYesterday(dateObj)) { | |
| if (options.time) { | |
| const timeFormat = options.time === '12h' ? 'h:mm a' : 'HH:mm'; | |
| return `yesterday at ${dateFnsFormat(dateObj, timeFormat)}`; | |
| } | |
| return 'yesterday'; | |
| } | |
| return formatDistanceToNow(dateObj, { addSuffix: true }); | |
| } | |
| /** | |
| * Format a date with flexible modifiers. | |
| * | |
| * @example | |
| * formatDate(date) // "Jan 15, 2024" | |
| * formatDate(date, { style: 'display', month: 'long' }) // "January 15, 2024" | |
| * formatDate(date, { style: 'display', day: 'ordinal' }) // "Jan 15th, 2024" | |
| * formatDate(date, { style: 'iso' }) // "2024-01-15" | |
| * formatDate(date, { style: 'time' }) // "14:30" | |
| */ | |
| export function formatDate( | |
| date: DateInput, | |
| options: DateFormatOptions = { style: 'display' }, | |
| ): string { | |
| const dateObj = parseDate(date); | |
| switch (options.style) { | |
| case 'iso': | |
| return dateFnsFormat(dateObj, 'yyyy-MM-dd'); | |
| case 'time': { | |
| const format = options.format || '24h'; | |
| const formatString = TIME_FORMATS[format]; | |
| return dateFnsFormat(dateObj, formatString); | |
| } | |
| case 'display': { | |
| if (options.relative) { | |
| const relativeResult = formatRelativeDate( | |
| dateObj, | |
| options, | |
| options.relative, | |
| ); | |
| if (relativeResult) { | |
| return relativeResult; | |
| } | |
| } | |
| const formatString = buildDisplayFormat(options); | |
| return dateFnsFormat(dateObj, formatString); | |
| } | |
| default: | |
| options satisfies never; | |
| throw new Error( | |
| `Unexpected date format style: ${(options as any).style}`, | |
| ); | |
| } | |
| } | |
| /** | |
| * Format a date as relative time. | |
| * @deprecated Use formatDate with relative option instead | |
| */ | |
| export function formatRelativeTime( | |
| date: DateInput, | |
| options?: { addSuffix?: boolean }, | |
| ): string { | |
| const dateObj = parseDate(date); | |
| return formatDistanceToNow(dateObj, { | |
| addSuffix: options?.addSuffix ?? true, | |
| }); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment