Files
numolus/src/store/format.ts
T
wkhandClaude Sonnet 5 e6cb18ab3c Datum- & Zeit-Formatierung: Serial-Zahlen, Auto-Erkennung, DATE/TIME/TODAY/NOW
Wie in jeder Tabellenkalkulation ist ein Datum intern einfach eine Zahl
("Serial": Tage seit 1899-12-30, Uhrzeit als Bruchteil eines Tages) --
Datums-Arithmetik (=fällig - rechnungsdatum, =rechnungsdatum + 30)
funktioniert dadurch automatisch, ganz ohne Änderung an Operatoren.

- Neues Modul engine/dates.ts: dateToSerial/timeSerial (Bausteine für
  DATE()/TIME(), Überlauf wie Excel erlaubt), todaySerial/nowSerial
  (lokales Kalenderdatum), serialToUtcDate (Anzeige), parseDateTimeLiteral
  (Eingabe-Erkennung -- bewusst nur ISO/Punkt-Daten + Uhrzeit, KEINE
  Schrägstrich-Daten, da Tag-/Monat-zuerst über die unterstützten Locales
  nicht eindeutig auflösbar ist; ein zu einer ungültigen/rollierten
  Kalenderdatum führender Kandidat wird strikt verworfen statt lautlos
  zu überlaufen).
- workbook.ts: literalValue() erkennt Datum/Zeit vor dem Zahl-Test und
  liefert direkt den Serial.
- functions.ts: neue eingebaute Funktionen DATE(jahr;monat;tag),
  TIME(std;min;sek), TODAY(), NOW() -- TODAY/NOW sind bewusst nicht
  volatil (dokumentierte Grenze: diese Engine rechnet rein
  vorgänger-getrieben neu, ohne Timer).
- store/types.ts + format.ts: CellNumFmt um 'date'/'time'/'datetime'
  erweitert; formatValue rendert über Intl.DateTimeFormat (UTC-Zeitzone,
  pro Locale), 'plain' bekommt eine manuelle, locale-neutrale
  Formatierung statt der Browser-Locale.
- store/document.ts: setCellInput erkennt eine getippte Datums-/
  Zeit-Eingabe automatisch und setzt das passende Zellformat -- wie
  Excel/Numbers --, überschreibt aber nie ein bereits explizit gesetztes
  Format.
- Zahlenformat-Dropdown (SettingsPanel) und JSON-Validierung
  (isCellNumFmt) brauchten keine Änderung -- beide sind bereits generisch
  über NUM_FORMATS.

Tests: 32 neue (dates.spec.ts neu; workbook.spec.ts: literalValue +
DATE/TIME/TODAY/NOW-Formeln inkl. Datums-Arithmetik; format.spec.ts neu:
formatValue über mehrere Locales; document.spec.ts: Auto-Format beim
Eintippen, überschreibt nie ein gesetztes Format, Namenskollision mit
den neuen Funktionen). 265 -> 297 Tests, alle grün. vue-tsc/eslint/build
sauber. Live per Playwright verifiziert (Auto-Erkennung, Formel-Ergebnisse
ohne Auto-Format, Datums-Arithmetik, Schrägstrich-Daten bleiben Text).

READMEs aktualisiert (Funktionen, Zahlenformat, neuer "Datum & Zeit"-
Abschnitt in engine/README.md, Bekannte Grenzen, Testzahl; nebenbei eine
veraltete Aussage zu Vergleichsoperatoren in "Bekannte Grenzen" korrigiert
-- die gibt es seit der IF-Funktion bereits).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 23:24:39 +02:00

142 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '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');
/** `'plain'` renders a fixed, locale-neutral ISO-ish shape by hand -- NOT
* via `Intl` with an `undefined` locale, which would silently fall back to
* the *browser's* locale instead of staying neutral. */
function formatDatePart(d: Date, locale: NumberLocale): string {
if (locale === 'plain') {
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
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 '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);
}