Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

7.7 kB Ruw bestand
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;
}