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>
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
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);
|
||
}
|