Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<string, Intl.NumberFormat>();
/**
* 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<T>(build: (tag: string) => T): (locale: Locale) => T {
const cache = new Map<Locale, T>();
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;
}