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>
This commit is contained in:
wkh
2026-09-02 23:24:39 +02:00
co-authored by Claude Sonnet 5
parent ec85c371e8
commit e6cb18ab3c
13 changed files with 636 additions and 11 deletions
+57 -2
View File
@@ -19,9 +19,10 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. |
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). |
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. `IF` ist hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte, kurzschließende Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. |
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`), `DATE/TIME/TODAY/NOW` als Tabelle `{ NAME → fn(values) }`. `IF` ist hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte, kurzschließende Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. |
| `dates.ts` | Datum/Zeit als Zahl ("Serial", Tage seit 1899-12-30): `dateToSerial`/`timeSerial` (Bausteine für `DATE`/`TIME`), `todaySerial`/`nowSerial`, `serialToUtcDate` (für die Anzeige), `parseDateTimeLiteral` (Eingabe-Erkennung, s. u. „Datum & Zeit"). |
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. |
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. |
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. `literalValue()` erkennt Datums-/Zeit-Eingaben (`dates.ts`) vor dem generischen Zahl-Test. |
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`-
@@ -315,6 +316,60 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem
eingebauten `FUNCTIONS.IF`-Eintrag).
## Datum & Zeit
Kein eigener Zellwert-Typ — ein Datum ist wie in jeder Tabellenkalkulation
einfach eine **Zahl** (`dates.ts`): das "Serial", Tage seit dem Nullpunkt
**1899-12-30** (Google-Sheets-Konvention: wie Excel, aber ohne dessen
historischen 1900-Schaltjahr-Bug), Uhrzeit als Bruchteil eines Tages
(`0.5` = 12:00). Serial 1 = 1899-12-31, Serial 2 = 1900-01-01. Alle
Berechnungen laufen über UTC-Felder (`Date.UTC`/`getUTC*`), damit ein Serial
unabhängig von der Browser-Zeitzone exakt rundtrippt. Weil ein Datum einfach
eine Zahl ist, funktionieren `+`/`-`/Vergleiche darauf automatisch — keine
Änderung an Operatoren nötig.
- **`dateToSerial(y, mo, d, h?, mi?, s?)`** — Baustein für `DATE()`, reines
`Date.UTC`, **Überlauf bewusst erlaubt** (`dateToSerial(2026, 13, 1)` =
2027-01-01), wie Excels `DATE()`.
- **`timeSerial(h, mi, s)`** — Baustein für `TIME()`, wrapt modulo 86400
Sekunden wie Excels `TIME()` (`timeSerial(25,0,0)` = der `1/24`-Anteil des
Folgetags).
- **`todaySerial()`/`nowSerial()`** — nutzen das **lokale** Kalenderdatum
(ein Datum ist ein Kalender-, kein Zeitzonen-Konzept), nicht UTC.
- **`parseDateTimeLiteral(raw)`** — erkennt eine rohe Zelleingabe als Datum/
Zeit/Datum+Zeit, genutzt sowohl von `workbook.ts`s `literalValue()` (Text →
Serial) als auch vom Store (`document.ts`s `setCellInput`, Auto-Format
beim Eintippen, s. u.). **Bewusst konservativ**: nur ISO (`2026-09-02`,
optional `[ T]HH:MM[:SS]`) und Punkt-Daten (`02.09.2026`, `02.09.26` —
2-stellig: 00–29 → 20xx, 30–99 → 19xx —, ebenfalls mit Uhrzeit) sowie reine
`HH:MM[:SS]`-Zeiten werden erkannt. **Schrägstrich-Daten (`02/09/2026`)
absichtlich nicht** — Tag-zuerst vs. Monat-zuerst ist über die
unterstützten Locales (en-US monatzuerst, alle anderen tagzuerst) nicht
eindeutig auflösbar; falsches Raten würde das Datum lautlos verfälschen,
das bleibt schlechter als es als Text stehen zu lassen. Ein intern über
`Date.UTC` gebauter Kandidat wird durch Rück-Dekomposition verifiziert, um
stillen Überlauf abzulehnen (`31.04.2026`, `30.02.2026` → **kein** Treffer,
bleibt Text) — anders als `dateToSerial`/`DATE()`, die Überlauf bewusst
zulassen.
- **`DATE`/`TIME`/`TODAY`/`NOW`** (`functions.ts`) — `DATE`/`TIME` erwarten
genau 3 Argumente (sonst `#ERR`), koerzieren jedes wie `evaluator.ts`s
`toNumber()` (null → 0, zahlenförmiger Text gecastet, sonst `#VALUE!`);
`TODAY`/`NOW` erwarten 0 Argumente. **Nicht volatil**: diese Engine
berechnet rein vorgänger-getrieben neu, ohne Timer/Hintergrundarbeit — eine
Zelle mit `=TODAY()`/`=NOW()` aktualisiert sich erst bei ihrer eigenen
nächsten Neuberechnung, nicht spontan mit der Zeit (dokumentierte Grenze).
- **Anzeige** (`store/format.ts`) — `CellNumFmt`-Werte `date`/`time`/
`datetime` rendern über `serialToUtcDate()` + `Intl.DateTimeFormat(locale,
{ timeZone: 'UTC', ... })`; das Pseudo-Locale `'plain'` bekommt eine
manuelle, locale-neutrale `YYYY-MM-DD`/`HH:MM`-Formatierung (bewusst nicht
`Intl` mit `undefined`-Locale, das würde lautlos die Host-Locale des
Browsers durchsickern lassen). Formel-Ergebnisse bekommen **kein**
automatisches Format — wie jedes andere Formelergebnis auch.
- **Auto-Format beim Eintippen** (`store/document.ts`s `setCellInput`) — eine
erkannte Literal-Eingabe (nicht `=`-Formeln) setzt automatisch das
passende `numFmt`, aber **nur** solange die Zelle noch `'auto'` ist; ein
bereits explizit gesetztes Format wird nie überschrieben.
## Neuberechnung
1. `setCell` reparst **eine** Zelle, aktualisiert ihre Graph-Kanten.