/* Fyro Finance — formattazione numeri e date. Unico punto dell'app che trasforma numeri in testo da mostrare. Regole: vedi "05 — Number formatting" nel design system. */ export type Locale = "en" | "it"; export type CurrencyCode = "EUR" | "USD" | "GBP" | "HKD" | "BGN"; const MINUS = "\u2212"; const NBSP = "\u00A0"; const DASH = "—"; const SYMBOLS: Record = { EUR: "€", USD: "$", GBP: "£", HKD: "HK$", BGN: "BGN" }; const MONTHS: Record = { en: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], it: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"], }; const isMissing = (v: unknown): boolean => v === null || v === undefined || (typeof v === "number" && !Number.isFinite(v)); /** Cifre con separatori del locale. IT raggruppa sempre le migliaia (1.234,56). */ function digits(abs: number, decimals: number, locale: Locale, grouping = true): string { const en = abs.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping: grouping }); if (locale === "en") return en; return en.replace(/,/g, "§").replace(/\./g, ",").replace(/§/g, "."); } /** Arrotonda a `decimals` e toglie lo zero negativo, così −0.00 diventa 0.00. */ function round(v: number, decimals: number): number { const f = 10 ** decimals; const r = Math.round(v * f) / f; return Object.is(r, -0) ? 0 : r; } function withSymbol(sign: string, num: string, currency: string, locale: Locale): string { const sym = SYMBOLS[currency] ?? currency; return locale === "en" ? `${sign}${sym}${NBSP}${num}` : `${sign}${num}${NBSP}${sym}`; } export interface MoneyOptions { locale?: Locale; /** Mostra "+" davanti ai positivi (delta, movimenti). */ signed?: boolean; /** Solo valore assoluto: righe dentro una sezione (−) del P&L, dove il segno è già nel titolo. */ magnitude?: boolean; decimals?: number; } /** € 1,234.56 · −€ 8,420.00 · 1.234,56 € · — (dato mancante) */ export function formatMoney(value: number | null | undefined, currency: CurrencyCode | string, opts: MoneyOptions = {}): string { if (isMissing(value)) return DASH; const { locale = "en", signed = false, magnitude = false, decimals = 2 } = opts; const r = round(value as number, decimals); const sign = r < 0 && !magnitude ? MINUS : r > 0 && signed ? "+" : ""; return withSymbol(sign, digits(Math.abs(r), decimals, locale), currency, locale); } /** Importo in tabella senza simbolo: la valuta sta nell'intestazione di colonna. */ export function formatAmount(value: number | null | undefined, opts: MoneyOptions = {}): string { if (isMissing(value)) return DASH; const { locale = "en", signed = false, magnitude = false, decimals = 2 } = opts; const r = round(value as number, decimals); const sign = r < 0 && !magnitude ? MINUS : r > 0 && signed ? "+" : ""; return sign + digits(Math.abs(r), decimals, locale); } /** KPI: € 840 · € 74.2k · € 1.24M | IT: 840 € · 74,2k € · 1,24 Mln € */ export function formatMoneyCompact(value: number | null | undefined, currency: CurrencyCode | string, opts: { locale?: Locale; signed?: boolean } = {}): string { if (isMissing(value)) return DASH; const { locale = "en", signed = false } = opts; const v = value as number; const abs = Math.abs(v); let num: string; if (abs >= 1_000_000) { num = digits(round(abs / 1_000_000, 2), 2, locale) + (locale === "en" ? "M" : `${NBSP}Mln`); } else if (abs >= 1_000) { const k = round(abs / 1_000, 1); num = k >= 1000 ? digits(round(abs / 1_000_000, 2), 2, locale) + (locale === "en" ? "M" : `${NBSP}Mln`) : digits(k, 1, locale) + "k"; } else { num = digits(round(abs, 0), 0, locale); } const isZero = round(v, 0) === 0 && abs < 1_000; const sign = isZero ? "" : v < 0 ? MINUS : signed ? "+" : ""; return withSymbol(sign, num, currency, locale); } /** 18.4% · 18,4%. `value` è già in punti percentuali (18.4, non 0.184). */ export function formatPct(value: number | null | undefined, opts: { locale?: Locale; decimals?: number; signed?: boolean } = {}): string { if (isMissing(value)) return DASH; const { locale = "en", decimals = 1, signed = false } = opts; const r = round(value as number, decimals); const sign = r < 0 ? MINUS : r > 0 && signed ? "+" : ""; return `${sign}${digits(Math.abs(r), decimals, locale)}%`; } /** Variazione % tra due valori: +8.3% · −1.6% · >999% · n/a (base zero o mancante). */ export function formatDelta(current: number | null | undefined, previous: number | null | undefined, opts: { locale?: Locale } = {}): string { const { locale = "en" } = opts; if (isMissing(current) || isMissing(previous) || previous === 0) return "n/a"; const pct = (((current as number) - (previous as number)) / Math.abs(previous as number)) * 100; if (Math.abs(pct) > 999) return `${pct < 0 ? MINUS : ""}>999%`; return formatPct(pct, { locale, signed: true }); } /** Variazione tra due margini, in punti: +1.2 pp · −1,6 p.p. */ export function formatPp(delta: number | null | undefined, opts: { locale?: Locale } = {}): string { const { locale = "en" } = opts; if (isMissing(delta)) return DASH; const r = round(delta as number, 1); const sign = r < 0 ? MINUS : r > 0 ? "+" : ""; return `${sign}${digits(Math.abs(r), 1, locale)}${NBSP}${locale === "en" ? "pp" : "p.p."}`; } /** Colore di un delta. higherIsBetter=false per i costi. */ export function deltaTone(current: number | null | undefined, previous: number | null | undefined, higherIsBetter = true): "positive" | "negative" | "neutral" { if (isMissing(current) || isMissing(previous) || previous === 0 || current === previous) return "neutral"; const up = (current as number) > (previous as number); return up === higherIsBetter ? "positive" : "negative"; } /** Tasso di cambio: sempre 6 decimali, niente separatore delle migliaia. 1.086420 · 1,086420 */ export function formatRate(rate: number | null | undefined, opts: { locale?: Locale } = {}): string { if (isMissing(rate)) return DASH; return digits(rate as number, 6, opts.locale ?? "en", false); } /** Legge "YYYY-MM-DD" come data locale (evita lo slittamento di un giorno per fuso orario). */ function toDate(d: string | Date): Date | null { if (d instanceof Date) return Number.isNaN(d.getTime()) ? null : d; const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); const p = new Date(d); return Number.isNaN(p.getTime()) ? null : p; } /** 12 Mar 2026 · 12 mar 2026 */ export function formatDate(d: string | Date | null | undefined, opts: { locale?: Locale } = {}): string { if (!d) return DASH; const date = toDate(d); if (!date) return DASH; const locale = opts.locale ?? "en"; return `${date.getDate()}${NBSP}${MONTHS[locale][date.getMonth()]}${NBSP}${date.getFullYear()}`; } /** Mar 2026 · mar 2026. monthIndex 0–11. */ export function formatMonth(year: number, monthIndex: number, opts: { locale?: Locale } = {}): string { return `${MONTHS[opts.locale ?? "en"][monthIndex]}${NBSP}${year}`; }