import { DEFAULT_CURRENCY } from './currency.js'; import { DEFAULT_LOCALE, FORMAT_LOCALES, type Locale } from './locale.js'; import { isComputed, type CellValue, type ColumnFormat, type ColumnMeta, type ColumnType } from './types.js'; /** * Every formatter in this file writes for a locale, passed in by the caller * and defaulting to English. * * In practice that locale is the *workspace's*, the same place the currency * lives — see `locale.ts` for why it sits there and not on the account. One * setting governs the words in the chrome and the separators inside numbers, * percentages and dates, so a Dutch workspace reads `€ 1.700,00` to everyone * who opens it, and a screenshot of a cell matches what a colleague sees. */ const currencyFormatters = new Map(); /** * A formatter per locale and currency, kept because building one is not cheap * and the grid formats a few thousand cells a scroll. * * The fraction digits are left to `Intl`: two for the euro and the dollar, * none for the yen. Pinning them at two would print ¥1,700.00, which is not a * price anybody in Tokyo has ever written. */ function currencyFormatter(locale: Locale, code: string): Intl.NumberFormat { const key = `${locale}:${code}`; const cached = currencyFormatters.get(key); if (cached) return cached; const tag = FORMAT_LOCALES[locale]; let formatter: Intl.NumberFormat; try { formatter = new Intl.NumberFormat(tag, { style: 'currency', currency: code }); } catch { formatter = new Intl.NumberFormat(tag, { style: 'currency', currency: DEFAULT_CURRENCY }); } currencyFormatters.set(key, formatter); return formatter; } /** One small cache per shape of formatter, all keyed by the reader's locale. */ function perLocale(build: (tag: string) => T): (locale: Locale) => T { const cache = new Map(); return (locale) => { const cached = cache.get(locale); if (cached) return cached; const built = build(FORMAT_LOCALES[locale]); cache.set(locale, built); return built; }; } const numberFormatter = perLocale((tag) => new Intl.NumberFormat(tag, { maximumFractionDigits: 4 })); const percentFormatter = perLocale( (tag) => new Intl.NumberFormat(tag, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1, }), ); const countFormatter = perLocale((tag) => new Intl.NumberFormat(tag, { maximumFractionDigits: 0 })); /** * Dates are stored as `YYYY-MM-DD` and rendered in the reader's order. The * formatter pins UTC because the stored value is a calendar date, not an * instant: parsed as UTC midnight, a formatter left on the browser's zone * would show yesterday to everyone west of Greenwich. */ const dateFormatter = perLocale((tag) => new Intl.DateTimeFormat(tag, { timeZone: 'UTC' })); /** A whole number of things — rows, columns, attempts — in the reader's locale. */ export function formatCount(n: number, locale: Locale = DEFAULT_LOCALE): string { return countFormatter(locale).format(n); } export function todayISO(): string { return new Date().toISOString().slice(0, 10); } export function nowISO(): string { return new Date().toISOString().replace('T', ' ').slice(0, 19); } /** How a column of this type reads when nobody has chosen anything. */ export function impliedFormat(type: ColumnType): ColumnFormat { if (type === 'currency') return 'currency'; if (type === 'number') return 'number'; if (type === 'date') return 'date'; return 'text'; } export function effectiveFormat(col: ColumnMeta): ColumnFormat { return col.format ?? impliedFormat(col.type); } /** * The formats worth offering for a column, beyond the one its type implies. * * Empty for most types, and that is the point. A format is a question about how * to render a number, so it is only a question at all where the type leaves it * open. A link renders as the label of the row it points at, a checkbox as a * tick, a date as the ISO string it stores, a select as the option chosen — * none of them have a rendering left to decide, and offering one a picker was * offering a setting that does nothing. * * Where it is a real question: * * - **Computed columns**, which have no storage type to read. `Qty * UnitPrice` * comes back a number and nothing in the schema says whether that is money, a * count or a ratio. This is the case the whole field exists for. * - **Number and currency**, which are numbers whose *unit* is still open: a * rate stored as 0.15 reads as 15%, a year stored as 2026 should not read as * 2,026, and money is money whichever of the two it is stored as. * * `date` is deliberately not offered anywhere: every date column already * renders as a date, in the reader's own order, and there is no second way to * render one worth choosing. It stays in the union because every date column * ever created stores it. */ export function formatChoices(type: ColumnType): ColumnFormat[] { // No `text` for a computed column: plain is already what it does with no // format at all, so offering it would be the same answer listed twice. if (isComputed(type)) return ['currency', 'number', 'percent']; if (type === 'number') return ['currency', 'percent', 'text']; if (type === 'currency') return ['number', 'percent', 'text']; return []; } /** Whether a format is one this type could actually apply. */ export function isFormatAllowed(type: ColumnType, format: ColumnFormat | null): boolean { if (format === null) return true; return format === impliedFormat(type) || formatChoices(type).includes(format); } export function isNumericColumn(col: ColumnMeta): boolean { if (col.type === 'number' || col.type === 'currency') return true; const f = col.format; return f === 'currency' || f === 'number' || f === 'percent'; } export interface FormatOptions { /** The row a link column points at, by its display value. */ label?: string | null; /** ISO code the workspace keeps its money in. Defaults to the euro. */ currency?: string; /** The reader's locale. Defaults to English. */ locale?: Locale; } /** Render a cell for display. */ export function formatCell(value: CellValue, col: ColumnMeta, opts: FormatOptions = {}): string { if (col.type === 'link') return opts.label ?? ''; if (col.type === 'checkbox') return value ? '✓' : ''; if (value === null || value === undefined || value === '') return ''; const locale = opts.locale ?? DEFAULT_LOCALE; switch (effectiveFormat(col)) { case 'currency': { const n = Number(value); return Number.isFinite(n) ? currencyFormatter(locale, opts.currency ?? DEFAULT_CURRENCY).format(n) : String(value); } case 'number': { const n = Number(value); return Number.isFinite(n) ? numberFormatter(locale).format(n) : String(value); } case 'percent': { const n = Number(value); return Number.isFinite(n) ? percentFormatter(locale).format(n) : String(value); } case 'date': { // Anything that is not a clean stored date — a formula result, a paste // that never validated — passes through untouched rather than guessed at. const s = String(value); return isValidDate(s) ? dateFormatter(locale).format(new Date(`${s}T00:00:00Z`)) : s; } default: return String(value); } } /** The text an editor starts with — raw, not pretty. */ export function editableValue(value: CellValue): string { if (value === null || value === undefined) return ''; if (typeof value === 'boolean') return value ? 'true' : 'false'; return String(value); } export function isValidDate(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const d = new Date(`${value}T00:00:00Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; }