307 lines
7.4 KiB
TypeScript
307 lines
7.4 KiB
TypeScript
|
|
/**
|
||
|
|
* Number Formatting Utilities
|
||
|
|
* Provides locale-aware number, currency, and measurement formatting
|
||
|
|
* Fixes floating point precision issues
|
||
|
|
*/
|
||
|
|
|
||
|
|
export interface FormatNumberOptions {
|
||
|
|
minimumFractionDigits?: number;
|
||
|
|
maximumFractionDigits?: number;
|
||
|
|
useGrouping?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface FormatCurrencyOptions extends FormatNumberOptions {
|
||
|
|
currency?: string;
|
||
|
|
currencyDisplay?: 'symbol' | 'code' | 'name';
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface FormatWeightOptions extends FormatNumberOptions {
|
||
|
|
unit?: 'kg' | 'g' | 'lb' | 'oz';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Round number to specified decimal places to avoid floating point errors
|
||
|
|
*/
|
||
|
|
export function roundToPrecision(value: number, decimals: number = 2): number {
|
||
|
|
const multiplier = Math.pow(10, decimals);
|
||
|
|
return Math.round(value * multiplier) / multiplier;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format number with locale-specific formatting
|
||
|
|
*/
|
||
|
|
export function formatNumber(
|
||
|
|
value: number | string,
|
||
|
|
locale: string = 'es-ES',
|
||
|
|
options: FormatNumberOptions = {}
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return '0';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Round to avoid floating point errors
|
||
|
|
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
|
||
|
|
|
||
|
|
const defaultOptions: Intl.NumberFormatOptions = {
|
||
|
|
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||
|
|
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||
|
|
useGrouping: options.useGrouping ?? true,
|
||
|
|
};
|
||
|
|
|
||
|
|
return new Intl.NumberFormat(locale, defaultOptions).format(rounded);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format currency with locale-specific formatting
|
||
|
|
*/
|
||
|
|
export function formatCurrency(
|
||
|
|
value: number | string,
|
||
|
|
locale: string = 'es-ES',
|
||
|
|
options: FormatCurrencyOptions = {}
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return '€0,00';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Round to avoid floating point errors
|
||
|
|
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
|
||
|
|
|
||
|
|
const defaultOptions: Intl.NumberFormatOptions = {
|
||
|
|
style: 'currency',
|
||
|
|
currency: options.currency ?? 'EUR',
|
||
|
|
currencyDisplay: options.currencyDisplay ?? 'symbol',
|
||
|
|
minimumFractionDigits: options.minimumFractionDigits ?? 2,
|
||
|
|
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||
|
|
};
|
||
|
|
|
||
|
|
return new Intl.NumberFormat(locale, defaultOptions).format(rounded);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format weight/mass with unit
|
||
|
|
*/
|
||
|
|
export function formatWeight(
|
||
|
|
value: number | string,
|
||
|
|
locale: string = 'es-ES',
|
||
|
|
options: FormatWeightOptions = {}
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return '0 kg';
|
||
|
|
}
|
||
|
|
|
||
|
|
const unit = options.unit ?? 'kg';
|
||
|
|
// Round to avoid floating point errors
|
||
|
|
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
|
||
|
|
|
||
|
|
const formatted = formatNumber(rounded, locale, {
|
||
|
|
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||
|
|
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||
|
|
useGrouping: options.useGrouping ?? true,
|
||
|
|
});
|
||
|
|
|
||
|
|
return `${formatted} ${unit}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format volume with unit
|
||
|
|
*/
|
||
|
|
export function formatVolume(
|
||
|
|
value: number | string,
|
||
|
|
locale: string = 'es-ES',
|
||
|
|
unit: 'L' | 'mL' | 'gal' = 'L',
|
||
|
|
decimals: number = 2
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return `0 ${unit}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const rounded = roundToPrecision(numValue, decimals);
|
||
|
|
const formatted = formatNumber(rounded, locale, {
|
||
|
|
maximumFractionDigits: decimals,
|
||
|
|
});
|
||
|
|
|
||
|
|
return `${formatted} ${unit}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format percentage
|
||
|
|
*/
|
||
|
|
export function formatPercentage(
|
||
|
|
value: number | string,
|
||
|
|
locale: string = 'es-ES',
|
||
|
|
decimals: number = 1
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return '0%';
|
||
|
|
}
|
||
|
|
|
||
|
|
const rounded = roundToPrecision(numValue, decimals);
|
||
|
|
const formatted = formatNumber(rounded, locale, {
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: decimals,
|
||
|
|
});
|
||
|
|
|
||
|
|
return `${formatted}%`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format compact number (1K, 1M, etc.)
|
||
|
|
*/
|
||
|
|
export function formatCompactNumber(
|
||
|
|
value: number | string,
|
||
|
|
locale: string = 'es-ES'
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return '0';
|
||
|
|
}
|
||
|
|
|
||
|
|
const rounded = roundToPrecision(numValue, 1);
|
||
|
|
|
||
|
|
return new Intl.NumberFormat(locale, {
|
||
|
|
notation: 'compact',
|
||
|
|
maximumFractionDigits: 1,
|
||
|
|
}).format(rounded);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format integer (no decimals)
|
||
|
|
*/
|
||
|
|
export function formatInteger(
|
||
|
|
value: number | string,
|
||
|
|
locale: string = 'es-ES'
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return '0';
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Intl.NumberFormat(locale, {
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: 0,
|
||
|
|
}).format(Math.round(numValue));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse localized number string to number
|
||
|
|
*/
|
||
|
|
export function parseLocalizedNumber(
|
||
|
|
value: string,
|
||
|
|
locale: string = 'es-ES'
|
||
|
|
): number {
|
||
|
|
// Get locale-specific decimal and group separators
|
||
|
|
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
|
||
|
|
const decimalSeparator = parts.find(p => p.type === 'decimal')?.value ?? ',';
|
||
|
|
const groupSeparator = parts.find(p => p.type === 'group')?.value ?? '.';
|
||
|
|
|
||
|
|
// Remove group separators and replace decimal separator with dot
|
||
|
|
const normalized = value
|
||
|
|
.replace(new RegExp(`\\${groupSeparator}`, 'g'), '')
|
||
|
|
.replace(decimalSeparator, '.');
|
||
|
|
|
||
|
|
return parseFloat(normalized);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format unit of measure with value
|
||
|
|
*/
|
||
|
|
export function formatMeasurement(
|
||
|
|
value: number | string,
|
||
|
|
unit: string,
|
||
|
|
locale: string = 'es-ES',
|
||
|
|
decimals: number = 2
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return `0 ${unit}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const rounded = roundToPrecision(numValue, decimals);
|
||
|
|
const formatted = formatNumber(rounded, locale, {
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: decimals,
|
||
|
|
});
|
||
|
|
|
||
|
|
return `${formatted} ${unit}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Safe division to avoid floating point errors
|
||
|
|
*/
|
||
|
|
export function safeDivide(
|
||
|
|
numerator: number,
|
||
|
|
denominator: number,
|
||
|
|
decimals: number = 2
|
||
|
|
): number {
|
||
|
|
if (denominator === 0) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
return roundToPrecision(numerator / denominator, decimals);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Safe multiplication to avoid floating point errors
|
||
|
|
*/
|
||
|
|
export function safeMultiply(
|
||
|
|
a: number,
|
||
|
|
b: number,
|
||
|
|
decimals: number = 2
|
||
|
|
): number {
|
||
|
|
return roundToPrecision(a * b, decimals);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Safe addition to avoid floating point errors
|
||
|
|
*/
|
||
|
|
export function safeAdd(
|
||
|
|
...values: number[]
|
||
|
|
): number {
|
||
|
|
const sum = values.reduce((acc, val) => acc + val, 0);
|
||
|
|
return roundToPrecision(sum, 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format quantity with appropriate unit of measure
|
||
|
|
* Handles common bakery units
|
||
|
|
*/
|
||
|
|
export function formatQuantity(
|
||
|
|
value: number | string,
|
||
|
|
unit: string,
|
||
|
|
locale: string = 'es-ES'
|
||
|
|
): string {
|
||
|
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
|
||
|
|
if (isNaN(numValue)) {
|
||
|
|
return `0 ${unit}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Determine decimals based on unit
|
||
|
|
let decimals = 2;
|
||
|
|
if (unit.toLowerCase() === 'unidades' || unit.toLowerCase() === 'units') {
|
||
|
|
decimals = 0;
|
||
|
|
} else if (['kg', 'l', 'lb'].includes(unit.toLowerCase())) {
|
||
|
|
decimals = 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
const rounded = roundToPrecision(numValue, decimals);
|
||
|
|
const formatted = formatNumber(rounded, locale, {
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: decimals,
|
||
|
|
});
|
||
|
|
|
||
|
|
return `${formatted} ${unit}`;
|
||
|
|
}
|