## Backend API (apps/api) - Express.js server with TypeScript - JWT authentication with access/refresh tokens - Multi-tenant middleware (schema per tenant) - Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts - SAT integration: CFDI 4.0 XML parser, FIEL authentication - Metrics engine: 50+ financial metrics (Core, Startup, Enterprise) - Rate limiting, CORS, Helmet security ## Frontend Web (apps/web) - Next.js 14 with App Router - Authentication pages: login, register, forgot-password - Dashboard layout with Sidebar and Header - Dashboard pages: overview, cash-flow, revenue, expenses, metrics - Zustand stores for auth and UI state - Theme support with flash prevention ## Database Package (packages/database) - PostgreSQL migrations with multi-tenant architecture - Public schema: plans, tenants, users, sessions, subscriptions - Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts - Tenant management functions - Seed data for plans and super admin ## Shared Package (packages/shared) - TypeScript types: auth, tenant, financial, metrics, reports - Zod validation schemas for all entities - Utility functions for formatting ## UI Package (packages/ui) - Chart components: LineChart, BarChart, AreaChart, PieChart - Data components: DataTable, MetricCard, KPICard, AlertBadge - PeriodSelector and Skeleton components ## Infrastructure - Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog - Makefile with 25+ development commands - Development scripts: dev-setup.sh, dev-down.sh - Complete .env.example template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
659 lines
18 KiB
TypeScript
659 lines
18 KiB
TypeScript
/**
|
|
* 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<CurrencyFormatOptions, 'minimumFractionDigits' | 'maximumFractionDigits'> = {}
|
|
): 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<DateFormatOptions, 'includeTime'> = {}
|
|
): 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<NumberFormatOptions, 'notation'> = {}
|
|
): 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]}`;
|
|
}
|
|
}
|