import { isError, serialToUtcDate, type CellValue } from '../engine'; import type { CellNumFmt } from './types'; /** * Locale used to render numeric values. `'plain'` is not a real locale -- it * renders the bare JS number (`.` decimal, no grouping). * * A *display* setting only. `rawString()` (CSV / XLSX export) stays * locale-neutral so round-trips are lossless. */ export type NumberLocale = | 'plain' | 'de-DE' | 'de-CH' | 'en-US' | 'en-GB' | 'fr-FR' | 'it-IT'; export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [ { id: 'plain', label: 'Ohne Trennzeichen · 1234.5' }, { id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' }, { id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' }, { id: 'en-US', label: 'English (US) · 1,234.5' }, { id: 'en-GB', label: 'English (UK) · 1,234.5' }, { id: 'fr-FR', label: 'Français (FR) · 1 234,5' }, { id: 'it-IT', label: 'Italiano (IT) · 1.234,5' }, ]; const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id)); export function isNumberLocale(x: unknown): x is NumberLocale { return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale); } export const DEFAULT_LOCALE: NumberLocale = 'de-DE'; /** Cell number-format options offered in the UI. */ export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [ { id: 'auto', label: 'Automatisch' }, { id: 'plain', label: 'Ohne Formatierung' }, { id: 'int', label: 'Ganzzahl · 1.235' }, { id: 'dec1', label: '1 Nachkommastelle · 1.234,6' }, { id: 'dec2', label: '2 Nachkommastellen · 1.234,56' }, { id: 'pct0', label: 'Prozent · 12 %' }, { id: 'pct2', label: 'Prozent · 12,34 %' }, { id: 'date', label: 'Datum · 02.09.2026' }, { id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' }, { id: 'time', label: 'Zeit · 14:30' }, { id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' }, ]; const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id)); export function isCellNumFmt(x: unknown): x is CellNumFmt { return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt); } const round10 = (n: number) => Math.round(n * 1e10) / 1e10; function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat { // for the pseudo-locale 'plain' use en-US digits but turn grouping off return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, { useGrouping: locale !== 'plain', ...opts, }); } const pad2 = (n: number) => String(n).padStart(2, '0'); /** `YYYY-MM-DD`, hand-written rather than via `Intl` -- there's no locale * that reliably reproduces ISO 8601, and this is also what the `'plain'` * pseudo-locale uses for a date (an `Intl` call with an `undefined` locale * would silently fall back to the *browser's* locale instead of staying * neutral). Shared by the locale-independent `'dateiso'` cell format and * by `formatDatePart`'s `'plain'` branch below. */ function isoDateString(d: Date): string { return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; } function formatDatePart(d: Date, locale: NumberLocale): string { if (locale === 'plain') return isoDateString(d); return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC', }).format(d); } function formatTimePart(d: Date, locale: NumberLocale): string { if (locale === 'plain') return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`; return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', timeZone: 'UTC', }).format(d); } /** Render a computed value for display (grid cell / status bar / chart). */ export function formatValue( v: CellValue, locale: NumberLocale = DEFAULT_LOCALE, numFmt: CellNumFmt = 'auto', ): string { if (v === null) return ''; if (isError(v)) return v.code; if (typeof v !== 'number') return v; if (!Number.isFinite(v)) return '#NUM!'; const n = round10(v); switch (numFmt) { case 'plain': return String(n); case 'int': return nf(locale, { maximumFractionDigits: 0 }).format(n); case 'dec1': return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n); case 'dec2': return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); case 'pct0': return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n); case 'pct2': return nf(locale, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(n); case 'date': return formatDatePart(serialToUtcDate(n), locale); case 'dateiso': return isoDateString(serialToUtcDate(n)); // same regardless of locale case 'time': return formatTimePart(serialToUtcDate(n), locale); case 'datetime': return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`; default: // 'auto' return locale === 'plain' ? String(n) : nf(locale, { maximumFractionDigits: 10 }).format(n); } } /** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */ export function rawString(v: CellValue): string { if (v === null) return ''; if (isError(v)) return v.code; if (typeof v === 'number') return String(round10(v)); return v; } export function isNumeric(v: CellValue): boolean { return typeof v === 'number' && Number.isFinite(v); }