/** * Formatting Utilities for Horux Strategy * Currency, percentage, date, and other formatting functions */ import { DEFAULT_LOCALE, DEFAULT_TIMEZONE, CURRENCIES, DEFAULT_CURRENCY } from '../constants'; // ============================================================================ // Currency Formatting // ============================================================================ export interface CurrencyFormatOptions { currency?: string; locale?: string; showSymbol?: boolean; showCode?: boolean; minimumFractionDigits?: number; maximumFractionDigits?: number; signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never'; } /** * Format a number as currency (default: Mexican Pesos) * * @example * formatCurrency(1234.56) // "$1,234.56" * formatCurrency(1234.56, { currency: 'USD' }) // "US$1,234.56" * formatCurrency(-1234.56) // "-$1,234.56" * formatCurrency(1234.56, { showCode: true }) // "$1,234.56 MXN" */ export function formatCurrency( amount: number, options: CurrencyFormatOptions = {} ): string { const { currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE, showSymbol = true, showCode = false, minimumFractionDigits, maximumFractionDigits, signDisplay = 'auto', } = options; const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY]; const decimals = minimumFractionDigits ?? maximumFractionDigits ?? currencyInfo.decimals; try { const formatter = new Intl.NumberFormat(locale, { style: showSymbol ? 'currency' : 'decimal', currency: showSymbol ? currency : undefined, minimumFractionDigits: decimals, maximumFractionDigits: maximumFractionDigits ?? decimals, signDisplay, }); let formatted = formatter.format(amount); // Add currency code if requested if (showCode) { formatted = `${formatted} ${currency}`; } return formatted; } catch { // Fallback formatting const symbol = showSymbol ? currencyInfo.symbol : ''; const sign = amount < 0 ? '-' : ''; const absAmount = Math.abs(amount).toFixed(decimals); const [intPart, decPart] = absAmount.split('.'); const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); let result = `${sign}${symbol}${formattedInt}`; if (decPart) { result += `.${decPart}`; } if (showCode) { result += ` ${currency}`; } return result; } } /** * Format currency for display in compact form * * @example * formatCurrencyCompact(1234) // "$1.2K" * formatCurrencyCompact(1234567) // "$1.2M" */ export function formatCurrencyCompact( amount: number, options: Omit = {} ): string { const { currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE, showSymbol = true, } = options; try { const formatter = new Intl.NumberFormat(locale, { style: showSymbol ? 'currency' : 'decimal', currency: showSymbol ? currency : undefined, notation: 'compact', compactDisplay: 'short', maximumFractionDigits: 1, }); return formatter.format(amount); } catch { // Fallback const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY]; const symbol = showSymbol ? currencyInfo.symbol : ''; const absAmount = Math.abs(amount); const sign = amount < 0 ? '-' : ''; if (absAmount >= 1000000000) { return `${sign}${symbol}${(absAmount / 1000000000).toFixed(1)}B`; } if (absAmount >= 1000000) { return `${sign}${symbol}${(absAmount / 1000000).toFixed(1)}M`; } if (absAmount >= 1000) { return `${sign}${symbol}${(absAmount / 1000).toFixed(1)}K`; } return `${sign}${symbol}${absAmount.toFixed(0)}`; } } /** * Parse a currency string to number * * @example * parseCurrency("$1,234.56") // 1234.56 * parseCurrency("-$1,234.56") // -1234.56 */ export function parseCurrency(value: string): number { // Remove currency symbols, spaces, and thousand separators const cleaned = value .replace(/[^0-9.,-]/g, '') .replace(/,/g, ''); const number = parseFloat(cleaned); return isNaN(number) ? 0 : number; } // ============================================================================ // Percentage Formatting // ============================================================================ export interface PercentFormatOptions { locale?: string; minimumFractionDigits?: number; maximumFractionDigits?: number; signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never'; multiply?: boolean; // If true, multiply by 100 (e.g., 0.16 -> 16%) } /** * Format a number as percentage * * @example * formatPercent(16.5) // "16.5%" * formatPercent(0.165, { multiply: true }) // "16.5%" * formatPercent(-5.2, { signDisplay: 'always' }) // "-5.2%" */ export function formatPercent( value: number, options: PercentFormatOptions = {} ): string { const { locale = DEFAULT_LOCALE, minimumFractionDigits = 0, maximumFractionDigits = 2, signDisplay = 'auto', multiply = false, } = options; const displayValue = multiply ? value : value / 100; try { const formatter = new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits, maximumFractionDigits, signDisplay, }); return formatter.format(displayValue); } catch { // Fallback const sign = signDisplay === 'always' && value > 0 ? '+' : ''; const actualValue = multiply ? value * 100 : value; return `${sign}${actualValue.toFixed(maximumFractionDigits)}%`; } } /** * Format a percentage change with color indicator * Returns an object with formatted value and direction */ export function formatPercentChange( value: number, options: PercentFormatOptions = {} ): { formatted: string; direction: 'up' | 'down' | 'unchanged'; isPositive: boolean; } { const formatted = formatPercent(value, { ...options, signDisplay: 'exceptZero' }); const direction = value > 0 ? 'up' : value < 0 ? 'down' : 'unchanged'; return { formatted, direction, isPositive: value > 0, }; } // ============================================================================ // Date Formatting // ============================================================================ export interface DateFormatOptions { locale?: string; timezone?: string; format?: 'short' | 'medium' | 'long' | 'full' | 'relative' | 'iso'; includeTime?: boolean; } /** * Format a date * * @example * formatDate(new Date()) // "31/01/2024" * formatDate(new Date(), { format: 'long' }) // "31 de enero de 2024" * formatDate(new Date(), { includeTime: true }) // "31/01/2024 14:30" */ export function formatDate( date: Date | string | number, options: DateFormatOptions = {} ): string { const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE, format = 'short', includeTime = false, } = options; const dateObj = date instanceof Date ? date : new Date(date); if (isNaN(dateObj.getTime())) { return 'Fecha inválida'; } // ISO format if (format === 'iso') { return dateObj.toISOString(); } // Relative format if (format === 'relative') { return formatRelativeTime(dateObj, locale); } try { const dateStyle = format === 'short' ? 'short' : format === 'medium' ? 'medium' : format === 'long' ? 'long' : 'full'; const formatter = new Intl.DateTimeFormat(locale, { dateStyle, timeStyle: includeTime ? 'short' : undefined, timeZone: timezone, }); return formatter.format(dateObj); } catch { // Fallback const day = dateObj.getDate().toString().padStart(2, '0'); const month = (dateObj.getMonth() + 1).toString().padStart(2, '0'); const year = dateObj.getFullYear(); let result = `${day}/${month}/${year}`; if (includeTime) { const hours = dateObj.getHours().toString().padStart(2, '0'); const minutes = dateObj.getMinutes().toString().padStart(2, '0'); result += ` ${hours}:${minutes}`; } return result; } } /** * Format a date as relative time (e.g., "hace 2 días") */ export function formatRelativeTime( date: Date | string | number, locale: string = DEFAULT_LOCALE ): string { const dateObj = date instanceof Date ? date : new Date(date); const now = new Date(); const diffMs = now.getTime() - dateObj.getTime(); const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffSeconds / 60); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); const diffWeeks = Math.floor(diffDays / 7); const diffMonths = Math.floor(diffDays / 30); const diffYears = Math.floor(diffDays / 365); try { const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); if (Math.abs(diffSeconds) < 60) { return rtf.format(-diffSeconds, 'second'); } if (Math.abs(diffMinutes) < 60) { return rtf.format(-diffMinutes, 'minute'); } if (Math.abs(diffHours) < 24) { return rtf.format(-diffHours, 'hour'); } if (Math.abs(diffDays) < 7) { return rtf.format(-diffDays, 'day'); } if (Math.abs(diffWeeks) < 4) { return rtf.format(-diffWeeks, 'week'); } if (Math.abs(diffMonths) < 12) { return rtf.format(-diffMonths, 'month'); } return rtf.format(-diffYears, 'year'); } catch { // Fallback for environments without Intl.RelativeTimeFormat if (diffSeconds < 60) return 'hace un momento'; if (diffMinutes < 60) return `hace ${diffMinutes} minuto${diffMinutes !== 1 ? 's' : ''}`; if (diffHours < 24) return `hace ${diffHours} hora${diffHours !== 1 ? 's' : ''}`; if (diffDays < 7) return `hace ${diffDays} día${diffDays !== 1 ? 's' : ''}`; if (diffWeeks < 4) return `hace ${diffWeeks} semana${diffWeeks !== 1 ? 's' : ''}`; if (diffMonths < 12) return `hace ${diffMonths} mes${diffMonths !== 1 ? 'es' : ''}`; return `hace ${diffYears} año${diffYears !== 1 ? 's' : ''}`; } } /** * Format a date range * * @example * formatDateRange(start, end) // "1 - 31 de enero de 2024" */ export function formatDateRange( start: Date | string | number, end: Date | string | number, options: Omit = {} ): string { const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE } = options; const startDate = start instanceof Date ? start : new Date(start); const endDate = end instanceof Date ? end : new Date(end); try { const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: timezone, }); return formatter.formatRange(startDate, endDate); } catch { // Fallback return `${formatDate(startDate, options)} - ${formatDate(endDate, options)}`; } } /** * Format time only */ export function formatTime( date: Date | string | number, options: { locale?: string; timezone?: string; style?: 'short' | 'medium' } = {} ): string { const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE, style = 'short' } = options; const dateObj = date instanceof Date ? date : new Date(date); try { const formatter = new Intl.DateTimeFormat(locale, { timeStyle: style, timeZone: timezone, }); return formatter.format(dateObj); } catch { const hours = dateObj.getHours().toString().padStart(2, '0'); const minutes = dateObj.getMinutes().toString().padStart(2, '0'); return `${hours}:${minutes}`; } } // ============================================================================ // Number Formatting // ============================================================================ export interface NumberFormatOptions { locale?: string; minimumFractionDigits?: number; maximumFractionDigits?: number; notation?: 'standard' | 'scientific' | 'engineering' | 'compact'; signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never'; } /** * Format a number with thousand separators * * @example * formatNumber(1234567.89) // "1,234,567.89" * formatNumber(1234567, { notation: 'compact' }) // "1.2M" */ export function formatNumber( value: number, options: NumberFormatOptions = {} ): string { const { locale = DEFAULT_LOCALE, minimumFractionDigits, maximumFractionDigits, notation = 'standard', signDisplay = 'auto', } = options; try { const formatter = new Intl.NumberFormat(locale, { minimumFractionDigits, maximumFractionDigits, notation, signDisplay, }); return formatter.format(value); } catch { return value.toLocaleString(); } } /** * Format a number in compact notation * * @example * formatCompactNumber(1234) // "1.2K" * formatCompactNumber(1234567) // "1.2M" */ export function formatCompactNumber( value: number, options: Omit = {} ): string { return formatNumber(value, { ...options, notation: 'compact' }); } /** * Format bytes to human readable size * * @example * formatBytes(1024) // "1 KB" * formatBytes(1536) // "1.5 KB" * formatBytes(1048576) // "1 MB" */ export function formatBytes( bytes: number, options: { locale?: string; decimals?: number } = {} ): string { const { locale = DEFAULT_LOCALE, decimals = 1 } = options; if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); const value = bytes / Math.pow(k, i); try { const formatter = new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals, }); return `${formatter.format(value)} ${sizes[i]}`; } catch { return `${value.toFixed(decimals)} ${sizes[i]}`; } } // ============================================================================ // Text Formatting // ============================================================================ /** * Truncate text with ellipsis * * @example * truncate("Hello World", 8) // "Hello..." */ export function truncate(text: string, maxLength: number, suffix = '...'): string { if (text.length <= maxLength) return text; return text.slice(0, maxLength - suffix.length).trim() + suffix; } /** * Capitalize first letter * * @example * capitalize("hello world") // "Hello world" */ export function capitalize(text: string): string { if (!text) return ''; return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); } /** * Title case * * @example * titleCase("hello world") // "Hello World" */ export function titleCase(text: string): string { return text .toLowerCase() .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } /** * Format RFC for display (with spaces) * * @example * formatRFC("XAXX010101000") // "XAXX 010101 000" */ export function formatRFC(rfc: string): string { const cleaned = rfc.replace(/\s/g, '').toUpperCase(); if (cleaned.length === 12) { // Persona física return `${cleaned.slice(0, 4)} ${cleaned.slice(4, 10)} ${cleaned.slice(10)}`; } if (cleaned.length === 13) { // Persona moral return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 9)} ${cleaned.slice(9)}`; } return cleaned; } /** * Mask sensitive data * * @example * maskString("1234567890", 4) // "******7890" * maskString("email@example.com", 3, { maskChar: '*', type: 'email' }) // "ema***@example.com" */ export function maskString( value: string, visibleChars: number = 4, options: { maskChar?: string; position?: 'start' | 'end' } = {} ): string { const { maskChar = '*', position = 'end' } = options; if (value.length <= visibleChars) return value; const maskLength = value.length - visibleChars; const mask = maskChar.repeat(maskLength); if (position === 'start') { return value.slice(0, visibleChars) + mask; } return mask + value.slice(-visibleChars); } /** * Format CLABE for display * * @example * formatCLABE("123456789012345678") // "123 456 789012345678" */ export function formatCLABE(clabe: string): string { const cleaned = clabe.replace(/\s/g, ''); if (cleaned.length !== 18) return clabe; return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)} ${cleaned.slice(6)}`; } /** * Format phone number (Mexican format) * * @example * formatPhone("5512345678") // "(55) 1234-5678" */ export function formatPhone(phone: string): string { const cleaned = phone.replace(/\D/g, ''); if (cleaned.length === 10) { return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`; } if (cleaned.length === 12 && cleaned.startsWith('52')) { const national = cleaned.slice(2); return `+52 (${national.slice(0, 2)}) ${national.slice(2, 6)}-${national.slice(6)}`; } return phone; } // ============================================================================ // Pluralization // ============================================================================ /** * Simple Spanish pluralization * * @example * pluralize(1, 'factura', 'facturas') // "1 factura" * pluralize(5, 'factura', 'facturas') // "5 facturas" */ export function pluralize( count: number, singular: string, plural: string ): string { const word = count === 1 ? singular : plural; return `${formatNumber(count)} ${word}`; } /** * Format a list of items with proper grammar * * @example * formatList(['a', 'b', 'c']) // "a, b y c" * formatList(['a', 'b']) // "a y b" * formatList(['a']) // "a" */ export function formatList( items: string[], options: { locale?: string; type?: 'conjunction' | 'disjunction' } = {} ): string { const { locale = DEFAULT_LOCALE, type = 'conjunction' } = options; if (items.length === 0) return ''; if (items.length === 1) return items[0]; try { const formatter = new Intl.ListFormat(locale, { style: 'long', type, }); return formatter.format(items); } catch { // Fallback const connector = type === 'conjunction' ? 'y' : 'o'; if (items.length === 2) { return `${items[0]} ${connector} ${items[1]}`; } return `${items.slice(0, -1).join(', ')} ${connector} ${items[items.length - 1]}`; } }