/** * Formats a number as currency with the given currency code. * * @param {number} amount - The numeric amount to format * @param {string} currency - ISO 4217 currency code (e.g. "EUR", "USD") * @param {string} locale - BCP 47 locale string, defaults to "en-US" * @returns {string} Formatted currency string (e.g. "€149.99") */ export function formatCurrency( amount: number, currency = 'EUR', locale = 'en-US' ): string { return new Intl.NumberFormat(locale, { style: 'currency', currency, }).format(amount); } /** * Formats a date string or Date object to a human-readable format. * * @param {string | Date} date - Date string or Date object * @param {Intl.DateTimeFormatOptions} options - Intl options for customization * @param {string} locale - BCP 47 locale string, defaults to "en-US" * @returns {string} Formatted date string (e.g. "Mar 11, 2026") */ export function formatDate( date: string | Date, options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric', }, locale = 'en-US' ): string { const dateObj = typeof date === 'string' ? new Date(date) : date; return new Intl.DateTimeFormat(locale, options).format(dateObj); } /** * The shortest relative label a history line can carry: "just now", "3d * ago", "2w ago". For prose that reads as a sentence use * {@link formatRelativeTime}. * * @param {string | null} iso - ISO timestamp of the moment, or nothing. * @param {number} [nowMs] - The moment to measure against; defaults to now. * @returns {string} The label, empty when there is no moment. */ export function agoLabel(iso: string | null, nowMs?: number): string { if (!iso) return ''; const minutes = Math.floor( ((nowMs ?? Date.now()) - new Date(iso).getTime()) / 60000 ); if (minutes < 2) return 'just now'; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 14) return `${days}d ago`; return `${Math.floor(days / 7)}w ago`; } /** * Formats a relative time string (e.g. "2 hours ago", "in 3 days"). * * ``nowMs`` exists for server-rendered callers. Left to read the clock itself, * this runs once on the server and again on hydration against a different * now, and the two can disagree - which React 19 treats as a hydration * mismatch and answers by throwing away the server's HTML. A page that * resolves one ``nowMs`` in its loader and passes it here renders the same * string on both passes. * * @param {string | Date} date - Date string or Date object * @param {string} locale - BCP 47 locale string, defaults to "en-US" * @param {number} [nowMs] - The moment to measure against; defaults to now * @returns {string} Relative time string */ export function formatRelativeTime( date: string | Date, locale = 'en-US', nowMs?: number ): string { const dateObj = typeof date === 'string' ? new Date(date) : date; const diffMs = dateObj.getTime() - (nowMs ?? Date.now()); const diffSec = Math.round(diffMs / 1000); const diffMin = Math.round(diffSec / 60); const diffHr = Math.round(diffMin / 60); const diffDay = Math.round(diffHr / 24); const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second'); if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute'); if (Math.abs(diffHr) < 24) return rtf.format(diffHr, 'hour'); return rtf.format(diffDay, 'day'); } /** * Formats a byte count into a human-readable file size string. * * @param {number} bytes - File size in bytes * @returns {string} Formatted file size (e.g. "1.5 MB") */ export function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B'; const KILOBYTE = 1024; const sizes = ['B', 'KB', 'MB']; const sizeIndex = Math.floor(Math.log(bytes) / Math.log(KILOBYTE)); return `${parseFloat((bytes / Math.pow(KILOBYTE, sizeIndex)).toFixed(1))} ${sizes[sizeIndex]}`; } /** * Formats a nullable number with locale separators and fixed decimals. * Returns "0" for null/undefined values. * * @param {number | null | undefined} value - Number to format. * @param {number} decimals - Decimal places, defaults to 0. * @returns {string} Formatted number string. */ export function formatNumber( value: number | null | undefined, decimals = 0 ): string { if (value == null) return '0'; return Number(value).toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }); } /** * Formats an ISO date string into short date with time. * Returns "-" for falsy values. * * @param {string | null | undefined} iso - ISO date string * @returns {string} Formatted date or "-" */ export function formatDateTime(iso: string | null | undefined): string { if (!iso) return '-'; return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } /** * Formats a nullable number as a percentage string (e.g. "12.5%"). * Returns "0%" for null/undefined values. * * @param {number | null | undefined} value - Percentage value. * @returns {string} Formatted percentage string. */ export function formatPercent(value: number | null | undefined): string { if (value == null) return '0%'; return Number(value).toFixed(1) + '%'; } /** * Formats a 0-1 purchase intent score as a percentage string. * Returns "-" for null/undefined values. * * @param {number | null | undefined} score - Intent score (0 to 1). * @returns {string} Formatted intent percentage. */ export function formatIntent(score: number | null | undefined): string { if (score == null) return '-'; return (score * 100).toFixed(0) + '%'; } /** * Formats a date in the viewer's own locale rather than en-US, the way a * calendar or a clock should read: a merchant in Berlin sees "14. Sep." and * one in Boston "Sep 14". Server output for these is never trusted - callers * render them after hydration, since the server cannot know the viewer's * locale. * * @param {string | Date} date - Date string or Date object * @param {Intl.DateTimeFormatOptions} options - Which parts to print * @returns {string} The formatted date */ export function formatLocalDate( date: string | Date, options: Intl.DateTimeFormatOptions ): string { const dateObj = typeof date === 'string' ? new Date(date) : date; return new Intl.DateTimeFormat(undefined, options).format(dateObj); } /** * The time of day in the viewer's own locale ("09:30" or "9:30 AM"). * Returns "" when the instant cannot be read. * * @param {string | Date} date - Date string or Date object * @param {"2-digit" | "numeric"} [hour] - Whether the hour keeps its leading zero; it does by default * @returns {string} The clock time, or "" */ export function formatLocalTime( date: string | Date, hour: '2-digit' | 'numeric' = '2-digit' ): string { const dateObj = typeof date === 'string' ? new Date(date) : date; if (Number.isNaN(dateObj.getTime())) return ''; return formatLocalDate(dateObj, { hour, minute: '2-digit' }); } /** * A day in the viewer's own locale, numeric ("9/13/2026" or "13.9.2026"). * Returns "" when the instant cannot be read. * * @param {string | Date | null | undefined} date - Date string or Date object * @returns {string} The day, or "" */ export function formatLocalDay(date: string | Date | null | undefined): string { if (!date) return ''; const dateObj = typeof date === 'string' ? new Date(date) : date; if (Number.isNaN(dateObj.getTime())) return ''; return formatLocalDate(dateObj, { year: 'numeric', month: 'numeric', day: 'numeric', }); } /** * A short day with its year in the viewer's own locale ("Apr 8, 2026"). * Returns "" when the instant cannot be read. * * @param {string | Date | null | undefined} date - Date string or Date object * @returns {string} The day, or "" */ export function formatLocalDayWithYear( date: string | Date | null | undefined ): string { if (!date) return ''; const dateObj = typeof date === 'string' ? new Date(date) : date; if (Number.isNaN(dateObj.getTime())) return ''; return formatLocalDate(dateObj, { year: 'numeric', month: 'short', day: 'numeric', }); } /** * A moment with its clock time in the viewer's own locale * ("Apr 8, 2026, 09:30 AM"). Returns the fallback when the instant cannot * be read. * * @param {string | Date | null | undefined} date - Date string or Date object * @param {string} [fallback] - What to print for an unreadable instant; a dash by default * @returns {string} The moment, or the fallback */ export function formatLocalDateTime( date: string | Date | null | undefined, fallback: string = '—' ): string { if (!date) return fallback; const dateObj = typeof date === 'string' ? new Date(date) : date; if (Number.isNaN(dateObj.getTime())) return fallback; return formatLocalDate(dateObj, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } /** * A count with its noun, agreeing in number: "1 member", "3 members". * * @param {number} count - How many * @param {string} singular - The noun for one * @param {string} [plural] - The noun for any other count; ``singular + "s"`` by default * @returns {string} The count and the noun */ export function pluralize( count: number, singular: string, plural: string = `${singular}s` ): string { return `${formatNumber(count)} ${count === 1 ? singular : plural}`; } /** * A date as a narrow column shows one - "7 Sep", carrying the year only when * it is not the current one, so a row cannot be read as the wrong September. * Viewer's locale; "" when there is no date. * * @param {string | null | undefined} iso - ISO date string * @param {number} [currentYear] - The year that goes unsaid; this year by default * @returns {string} The short date, or "" */ export function formatShortDate( iso: string | null | undefined, currentYear: number = new Date().getFullYear() ): string { if (!iso) return ''; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; return formatLocalDate(date, { day: 'numeric', month: 'short', year: date.getFullYear() === currentYear ? undefined : 'numeric', }); } /** * Builds a short display name from an email address (local part, capitalised). * * @param {string} [email] - Full email address * @returns {string} Display name derived from the local part, or empty string */ export function displayNameFromEmail(email?: string): string { const local = email?.split('@')[0] ?? ''; if (!local) return ''; return local.charAt(0).toUpperCase() + local.slice(1); }