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
+50 -5
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash
npm install
npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 265 Tests
npm run test # Vitest-Suite (Engine + Store + IO), 297 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
@@ -263,7 +263,8 @@ Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) →
Inspector unter **Objekt · Auswahl**: horizontale **Ausrichtung**
(links/mitte/rechts),
**Zahlenformat** (`Automatisch`, `Ohne`, Ganzzahl, 1/2 Nachkommastellen,
**Prozent** 0/2 Stellen — kombiniert mit der Locale unten), **Zell-Textfarbe**,
**Prozent** 0/2 Stellen, **Datum**/**Zeit**/**Datum + Zeit** — kombiniert mit
der Locale unten), **Zell-Textfarbe**,
**Zell-Füllfarbe**, **Fett**/**Kursiv**, „Formatierung entfernen". Auch exakte
**Spaltenbreite** und **Zeilenhöhe** der Fokuszelle.
@@ -399,7 +400,8 @@ darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die
vier Kombinationen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)` — plus alle über
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat;
Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()` — plus alle über
**„𝑓 Funktionen"** angelegten benutzerdefinierten Funktionen (siehe
„Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch
verhalten. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `Name!…`),
@@ -416,6 +418,33 @@ Zahl außer `0` sowie jeder nicht-leere Text gilt als wahr, z. B.
Bedingung `0`. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name
kollidiert mit der eingebauten Funktion.
**Datum & Zeit**: ein Datum ist — wie in jeder Tabellenkalkulation — intern
einfach eine **Zahl** (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines
Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz
ohne Zusatzaufwand: `=fällig - rechnungsdatum` zählt Tage, `=rechnungsdatum
+ 30` addiert 30 Tage. Zwei Wege zu einem Datumswert:
- **Eingabe erkennen**: ein eindeutig lesbares Datum/eine Uhrzeit als reine
Zelleingabe (nicht als Formel) wird beim Eintippen automatisch erkannt
und sofort mit dem passenden Format angezeigt — genau wie in Excel/Numbers.
Erkannt werden ISO (`2026-09-02`, auch mit `14:30`/`14:30:15` angehängt)
und Punkt-Daten (`02.09.2026`, `02.09.26`, ebenfalls mit Uhrzeit), sowie
reine Uhrzeiten (`14:30`). **Absichtlich nicht** erkannt: Schrägstrich-Daten
(`02/09/2026`) — Tag-zuerst vs. Monat-zuerst ist über die unterstützten
Locales (en-US ist monatzuerst, alle anderen tagzuerst) nicht eindeutig
auflösbar; falsches Raten würde das Datum lautlos verfälschen, deshalb
bleibt so ein Text unangetastet. Die automatische Formatierung überschreibt
nie ein bereits explizit gesetztes Zellformat.
- **Formel**: `DATE(Jahr; Monat; Tag)` und `TIME(Std; Min; Sek)` bauen einen
Datums-/Zeitwert (Überlauf wie in Excel erlaubt: `DATE(2026;13;1)` =
01.01.2027, `TIME(25;0;0)` = 01:00 des Folgetags-Anteils); `TODAY()` /
`NOW()` liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse
bekommen **kein** automatisches Zellformat (wie bei jeder anderen Formel
auch) — das Zahlenformat-Dropdown auf „Datum"/„Zeit"/„Datum + Zeit" stellen.
`TODAY`/`NOW` sind **nicht volatil**: diese Engine rechnet rein
vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()`
aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von
selbst beim Tageswechsel (siehe „Bekannte Grenzen" unten).
**Fehlermodell** (propagiert an abhängige Zellen):
`#REF` (ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, oder ein
unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch
@@ -532,7 +561,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests
`npm run test` — Vitest, 265 Tests:
`npm run test` — Vitest, 297 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -547,6 +576,15 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung,
`IF` (beide Zweige, 2-/3-Argument-Form, Kurzschluss-Beweis, Vergleiche als
Bedingung, Fehler in der Bedingung).
- **Datum & Zeit** — `dates.ts`: `dateToSerial`/`timeSerial`-Rundtrip gegen
bekannte Werte, Überlauf-Verhalten, `parseDateTimeLiteral` für jede
erkannte Form (ISO/Punkt, mit/ohne Uhrzeit) sowie Ablehnung von
Schrägstrich-Daten und ungültigen/rollierten Daten; `literalValue` wandelt
eine erkannte Eingabe in ihren Serial um; `DATE`/`TIME`/`TODAY`/`NOW` als
Formel (Basisfälle, Arity-/Typ-Fehler, Datums-Arithmetik); `formatValue`
über mehrere Locales inkl. `plain`; Store: Auto-Format beim Eintippen
(überschreibt nie ein bereits gesetztes Format), Namenskollision mit den
neuen eingebauten Funktionen.
- **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus.
- **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch
die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt
@@ -683,9 +721,16 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
reaktiv), **kein** echter Web Worker.
- **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von
Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
- Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche (nur
- Kein Text-Verkettungsoperator, keine benannten Bereiche (nur
benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein
Snapping der Canvas-Objekte.
- **Datum & Zeit**: die automatische Erkennung beim Eintippen erfasst nur ISO-
und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs.
Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar,
s. o.). Formel-Ergebnisse (`=DATE(...)`, `=TODAY()`, `=A1+30`) bekommen kein
automatisches Zellformat — wie jedes andere Formelergebnis auch. `TODAY()`/
`NOW()` sind nicht volatil: sie aktualisieren sich erst bei der nächsten
Neuberechnung ihrer eigenen Zelle, nicht spontan mit der Zeit.
- **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne:
Autovervollständigen des Funktionsnamens im Editor, Live-Vorschau des
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
+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.
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import {
dateToSerial,
timeSerial,
serialToUtcDate,
todaySerial,
parseDateTimeLiteral,
} from '../dates';
describe('dateToSerial / serialToUtcDate', () => {
it('epoch is 1899-12-30: serial 1 = 1899-12-31, serial 2 = 1900-01-01', () => {
expect(dateToSerial(1899, 12, 31)).toBe(1);
expect(dateToSerial(1900, 1, 1)).toBe(2);
});
it('round-trips through serialToUtcDate', () => {
const s = dateToSerial(2026, 9, 2);
const d = serialToUtcDate(s);
expect(d.getUTCFullYear()).toBe(2026);
expect(d.getUTCMonth()).toBe(8); // 0-based
expect(d.getUTCDate()).toBe(2);
});
it('allows overflow, like Excel DATE() (month 13 -> next January)', () => {
const s = dateToSerial(2026, 13, 1);
const d = serialToUtcDate(s);
expect(d.getUTCFullYear()).toBe(2027);
expect(d.getUTCMonth()).toBe(0);
expect(d.getUTCDate()).toBe(1);
});
it('carries a fractional part for a time-of-day component', () => {
const s = dateToSerial(2026, 9, 2, 12, 0, 0);
expect(s % 1).toBeCloseTo(0.5, 10);
});
});
describe('timeSerial', () => {
it('noon = 0.5, midnight = 0', () => {
expect(timeSerial(12, 0, 0)).toBeCloseTo(0.5, 10);
expect(timeSerial(0, 0, 0)).toBe(0);
});
it('wraps past 24h like Excel TIME()', () => {
expect(timeSerial(25, 0, 0)).toBeCloseTo(1 / 24, 10);
});
});
describe('todaySerial', () => {
it('matches the local calendar date', () => {
const n = new Date();
expect(todaySerial()).toBe(Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate())));
});
});
describe('parseDateTimeLiteral', () => {
it('recognizes ISO dates', () => {
const r = parseDateTimeLiteral('2026-09-02');
expect(r).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2) });
});
it('recognizes ISO datetimes (space or T separator, optional seconds)', () => {
expect(parseDateTimeLiteral('2026-09-02 14:30')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
});
expect(parseDateTimeLiteral('2026-09-02T14:30:15')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 15),
});
});
it('recognizes dot dates, 4- and 2-digit years', () => {
expect(parseDateTimeLiteral('02.09.2026')).toEqual({
kind: 'date',
serial: dateToSerial(2026, 9, 2),
});
expect(parseDateTimeLiteral('02.09.26')).toEqual({
kind: 'date',
serial: dateToSerial(2026, 9, 2),
});
expect(parseDateTimeLiteral('02.09.99')).toEqual({
kind: 'date',
serial: dateToSerial(1999, 9, 2),
});
});
it('recognizes dot datetimes', () => {
expect(parseDateTimeLiteral('02.09.2026 14:30')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
});
});
it('recognizes plain times', () => {
expect(parseDateTimeLiteral('14:30')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 0) });
expect(parseDateTimeLiteral('14:30:15')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 15) });
});
it('does NOT recognize slash-separated dates (day-first/month-first ambiguity)', () => {
expect(parseDateTimeLiteral('02/09/2026')).toBeNull();
});
it('rejects an out-of-range or rolled-over date instead of silently guessing', () => {
expect(parseDateTimeLiteral('31.04.2026')).toBeNull(); // April has 30 days
expect(parseDateTimeLiteral('30.02.2026')).toBeNull(); // no Feb 30
expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
});
it('rejects an out-of-range time', () => {
expect(parseDateTimeLiteral('25:00')).toBeNull();
expect(parseDateTimeLiteral('12:60')).toBeNull();
});
it('does not misfire on plain numbers or arbitrary text', () => {
expect(parseDateTimeLiteral('1234.5')).toBeNull();
expect(parseDateTimeLiteral('hello')).toBeNull();
expect(parseDateTimeLiteral('')).toBeNull();
});
});
+61 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { Workbook, type TableModel } from '../workbook';
import { Workbook, literalValue, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
import { dateToSerial, timeSerial, todaySerial, nowSerial } from '../dates';
function sheet(
id: string,
@@ -223,3 +224,62 @@ describe('Workbook sheet qualifier ("Blatt!Tabelle::Zelle") -- table names uniqu
expect((val(wb2, 's-x', 'B2') as FormulaError).code).toBe('#REF');
});
});
describe('literalValue: date/time literal recognition', () => {
it('turns a recognized date/time literal into its serial number', () => {
expect(literalValue('2026-09-02')).toBe(dateToSerial(2026, 9, 2));
expect(literalValue('02.09.2026')).toBe(dateToSerial(2026, 9, 2));
expect(literalValue('14:30')).toBe(timeSerial(14, 30, 0));
});
it('leaves ordinary numbers and text untouched', () => {
expect(literalValue('1234.5')).toBe(1234.5);
expect(literalValue('-3')).toBe(-3);
expect(literalValue('hello')).toBe('hello');
expect(literalValue('02/09/2026')).toBe('02/09/2026'); // slash dates: not recognized, stays text
});
it('empty input is null', () => {
expect(literalValue('')).toBeNull();
expect(literalValue(' ')).toBeNull();
});
});
describe('Workbook: DATE/TIME/TODAY/NOW formulas', () => {
it('DATE/TIME build a serial usable in cell arithmetic', () => {
const wb = new Workbook([
sheet('s1', 'S1', {
A1: '=DATE(2026;9;2)',
A2: '=DATE(2026;9;1)',
A3: '=A1-A2', // date arithmetic: just numbers
B1: '=TIME(12;0;0)',
}),
]);
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2026, 9, 2));
expect(val(wb, 's1', 'A3')).toBe(1);
expect(val(wb, 's1', 'B1')).toBeCloseTo(0.5, 10);
});
it('DATE overflows like Excel, TIME wraps like Excel', () => {
const wb = new Workbook([
sheet('s1', 'S1', { A1: '=DATE(2026;13;1)', B1: '=TIME(25;0;0)' }),
]);
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2027, 1, 1));
expect(val(wb, 's1', 'B1')).toBeCloseTo(timeSerial(25, 0, 0), 10);
});
it('DATE/TIME reject the wrong argument count or a non-numeric argument', () => {
const wb = new Workbook([
sheet('s1', 'S1', { A1: '=DATE(2026;9)', A2: '=DATE(2026;9;"x")' }),
]);
expect(isError(val(wb, 's1', 'A1'))).toBe(true);
expect(isError(val(wb, 's1', 'A2'))).toBe(true);
});
it('TODAY/NOW take no arguments and return a plausible current serial', () => {
const wb = new Workbook([sheet('s1', 'S1', { A1: '=TODAY()', A2: '=NOW()', A3: '=TODAY(1)' })]);
expect(val(wb, 's1', 'A1')).toBe(todaySerial());
expect(val(wb, 's1', 'A2') as number).toBeCloseTo(nowSerial(), 2);
expect(isError(val(wb, 's1', 'A3'))).toBe(true);
});
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Dates & times as plain numbers -- "serial days" since a fixed epoch,
* exactly like every other spreadsheet (Excel, Google Sheets, Numbers): the
* integer part is the calendar day, the fractional part is the time of day
* (0 = midnight, 0.5 = noon). Epoch = 1899-12-30 (the Google Sheets / Excel
* convention, WITHOUT Excel's historical 1900-leap-year bug), so serial 1 =
* 1899-12-31, serial 2 = 1900-01-01. This is the whole reason date
* arithmetic (`=faelligkeit - rechnungsdatum`, `=rechnungsdatum + 30`) just
* works with the existing `+`/`-` operators -- no engine change needed
* there at all.
*
* All arithmetic goes through UTC fields (`Date.UTC` / `getUTC*`) so a
* serial round-trips exactly regardless of the browser's local timezone.
*/
import { FormulaError, isError, type CellValue } from './types';
const EPOCH_MS = Date.UTC(1899, 11, 30);
const MS_PER_DAY = 86_400_000;
/** `DATE()`'s building block -- plain `Date.UTC`, overflow allowed on
* purpose (`dateToSerial(2026, 13, 1)` = 2027-01-01), matching Excel's
* `DATE()`. Contrast with the stricter, overflow-rejecting builder used by
* {@link parseDateTimeLiteral} below for *recognizing typed input*. */
export function dateToSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number {
return (Date.UTC(y, mo - 1, d, h, mi, s) - EPOCH_MS) / MS_PER_DAY;
}
/** `TIME()`'s building block -- wraps like Excel's TIME (`TIME(25,0,0)` is
* the 1:00 fraction of the *next* day, i.e. `0.041666...`). */
export function timeSerial(h: number, mi: number, s: number): number {
const total = (((h * 3600 + mi * 60 + s) % 86400) + 86400) % 86400;
return total / 86400;
}
/** Serial -> UTC `Date`, for display formatting. */
export function serialToUtcDate(serial: number): Date {
return new Date(EPOCH_MS + Math.round(serial * MS_PER_DAY));
}
/** Today's *local* calendar date (a date is a calendar concept, not a
* timezone-relative instant), as an integer serial (no time-of-day part). */
export function todaySerial(): number {
const n = new Date();
return Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate()));
}
/** Local date + time, as a serial with a fractional part.
*
* Known limitation (documented in both READMEs): this engine recalculates
* incrementally, only when a precedent cell changes -- there's no timer or
* background work. A cell holding `=TODAY()`/`=NOW()` is NOT "volatile"
* the way Excel's is: it refreshes the next time *that cell itself* is
* re-edited or recalculated, not spontaneously when the clock ticks over. */
export function nowSerial(): number {
const n = new Date();
return dateToSerial(
n.getFullYear(),
n.getMonth() + 1,
n.getDate(),
n.getHours(),
n.getMinutes(),
n.getSeconds() + n.getMilliseconds() / 1000,
);
}
export type DateLiteralKind = 'date' | 'time' | 'datetime';
const ISO_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
const DOT_DATETIME_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
const DOT_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
const TIME_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
/** 2-digit year -> 4-digit, the usual spreadsheet convention: 00-29 -> 20xx,
* 30-99 -> 19xx. */
function expandYear(yStr: string): number {
if (yStr.length === 4) return Number(yStr);
const yy = Number(yStr);
return yy <= 29 ? 2000 + yy : 1900 + yy;
}
/** Strict date builder for *recognizing typed input* -- unlike `dateToSerial`
* (used by the `DATE()` formula function), an out-of-range or rolled-over
* component (`31.04.2026`, `30.02.2026`, hour `25`) means "this text isn't
* actually a date" rather than "roll over to the next month": leaving it as
* plain text beats silently guessing. Builds the candidate via `Date.UTC`,
* then verifies it by reading the fields back. */
function strictDateSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number | null {
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) return null;
const ms = Date.UTC(y, mo - 1, d, h, mi, s);
const check = new Date(ms);
if (check.getUTCFullYear() !== y || check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) {
return null;
}
return (ms - EPOCH_MS) / MS_PER_DAY;
}
/**
* Recognize a raw cell literal as a date, time, or date+time -- used both to
* turn typed input into its serial number ({@link workbook.ts}'s
* `literalValue`) and to decide whether to auto-apply a matching display
* format on entry (`store/document.ts`'s `setCellInput`).
*
* Deliberately locale-independent and conservative: only ISO dates
* (`2026-09-02`) and dot-separated day.month.year dates (`02.09.2026`,
* `02.09.26`) are recognized, plus `HH:MM[:SS]` time and the two combined
* with a space or `T`. Slash-separated dates (`02/09/2026`) are
* intentionally NOT recognized: day-first vs. month-first is ambiguous
* across this app's supported locales (en-US is month-first, every other
* supported locale is day-first) -- guessing wrong would silently corrupt
* the entered date, which is worse than leaving it as plain text.
*/
export function parseDateTimeLiteral(
raw: string,
): { kind: DateLiteralKind; serial: number } | null {
const t = raw.trim();
let m = ISO_DATETIME_RE.exec(t);
if (m) {
const serial = strictDateSerial(+m[1], +m[2], +m[3], +m[4], +m[5], m[6] ? +m[6] : 0);
return serial === null ? null : { kind: 'datetime', serial };
}
m = ISO_DATE_RE.exec(t);
if (m) {
const serial = strictDateSerial(+m[1], +m[2], +m[3]);
return serial === null ? null : { kind: 'date', serial };
}
m = DOT_DATETIME_RE.exec(t);
if (m) {
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1], +m[4], +m[5], m[6] ? +m[6] : 0);
return serial === null ? null : { kind: 'datetime', serial };
}
m = DOT_DATE_RE.exec(t);
if (m) {
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1]);
return serial === null ? null : { kind: 'date', serial };
}
m = TIME_RE.exec(t);
if (m) {
const h = +m[1];
const mi = +m[2];
const s = m[3] ? +m[3] : 0;
if (h > 23 || mi > 59 || s > 59) return null;
return { kind: 'time', serial: timeSerial(h, mi, s) };
}
return null;
}
/** Coerce a function argument to a number the way `evaluator.ts`'s
* `toNumber()` does for arithmetic (null -> 0, numeric string cast,
* anything else -> `#VALUE!`) -- duplicated here rather than imported
* because `functions.ts` (and this module, which it uses) must not import
* from `evaluator.ts` (evaluator.ts imports functions.ts, not the other
* way around). Used by `DATE`/`TIME`'s argument handling in functions.ts. */
export function strictNum(v: CellValue): number | FormulaError {
if (isError(v)) return v;
if (v === null) return 0;
if (typeof v === 'number') return v;
const trimmed = v.trim();
if (trimmed === '') return 0;
const n = Number(trimmed);
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
}
+45
View File
@@ -14,6 +14,7 @@
*/
import { FormulaError, isError, type CellValue } from './types';
import { dateToSerial, timeSerial, todaySerial, nowSerial, strictNum } from './dates';
export type FunctionFn = (values: CellValue[]) => CellValue;
@@ -70,6 +71,50 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
if (truthy) return thenValue;
return values.length === 3 ? elseValue : 0;
},
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
// same as everywhere else in this engine; a non-numeric text argument is
// `#VALUE!`.
DATE(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'DATE expects 3 arguments (Jahr; Monat; Tag)');
}
const y = strictNum(values[0]);
if (isError(y)) return y;
const mo = strictNum(values[1]);
if (isError(mo)) return mo;
const d = strictNum(values[2]);
if (isError(d)) return d;
return dateToSerial(y, mo, d);
},
TIME(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'TIME expects 3 arguments (Stunde; Minute; Sekunde)');
}
const h = strictNum(values[0]);
if (isError(h)) return h;
const mi = strictNum(values[1]);
if (isError(mi)) return mi;
const s = strictNum(values[2]);
if (isError(s)) return s;
return timeSerial(h, mi, s);
},
// Not "volatile": this engine only recalculates on precedent change, so a
// cell holding =TODAY()/=NOW() refreshes when THAT cell is next re-edited
// or recalculated, not spontaneously when the clock ticks over (documented
// limitation, see README.md's "Bekannte Grenzen").
TODAY(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'TODAY erwartet keine Argumente');
return todaySerial();
},
NOW(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'NOW erwartet keine Argumente');
return nowSerial();
},
};
// AVERAGE is an alias of AVG.
+9
View File
@@ -33,6 +33,15 @@ export {
literalValue,
type TableModel,
} from './workbook';
export {
dateToSerial,
timeSerial,
serialToUtcDate,
todaySerial,
nowSerial,
parseDateTimeLiteral,
type DateLiteralKind,
} from './dates';
export {
buildFunctionIndex,
expandUserFunctions,
+9 -1
View File
@@ -22,6 +22,7 @@
import { DependencyGraph } from './graph';
import { collectRefs, parseFormula, type Node } from './parser';
import { expandRange } from './refs';
import { parseDateTimeLiteral } from './dates';
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
import {
FormulaError,
@@ -53,10 +54,17 @@ function localKey(col: number, row: number): string {
return `${col},${row}`;
}
/** Interpret a raw literal (non-formula) cell input. */
/** Interpret a raw literal (non-formula) cell input. A recognized date/time
* (see dates.ts's parseDateTimeLiteral) becomes its serial number -- dates
* are just numbers in this engine, so `+`/`-`/comparisons work on them for
* free. Checked before NUMBER_RE; the two never collide (dates use `-`/`.`
* multi-part separators or `:`, which NUMBER_RE's single-decimal-point
* shape never matches). */
export function literalValue(raw: string): CellValue {
const t = raw.trim();
if (t === '') return null;
const dt = parseDateTimeLiteral(t);
if (dt) return dt.serial;
if (NUMBER_RE.test(t)) return Number(t);
return raw;
}
+42
View File
@@ -347,6 +347,39 @@ describe('cell formatting, number formats, row height', () => {
expect(s.cellDisplay(id, 6, 0)).toBe('0');
});
it('typing a recognizable date auto-applies the date format', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
s.setCellInput(id, 6, 0, '02.09.2026'); // G1, still 'auto' beforehand
expect(s.cellFormat(id, 6, 0)).toEqual({ numFmt: 'date' });
expect(s.cellDisplay(id, 6, 0)).toBe('02.09.2026');
});
it('typing a recognizable time auto-applies the time format', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
s.setCellInput(id, 6, 0, '14:30');
expect(s.cellFormat(id, 6, 0)).toEqual({ numFmt: 'time' });
expect(s.cellDisplay(id, 6, 0)).toBe('14:30');
});
it('date auto-recognition never overrides an explicit numFmt already on the cell', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
s.setRangeFormat(id, { col: 6, row: 0 }, { col: 6, row: 0 }, { numFmt: 'int' });
s.setCellInput(id, 6, 0, '02.09.2026');
expect(s.cellFormat(id, 6, 0)).toEqual({ numFmt: 'int' }); // untouched
});
it('a plain number or unrecognized text does not touch numFmt', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
s.setCellInput(id, 6, 0, '1234.5');
expect(s.cellFormat(id, 6, 0)).toEqual({});
s.setCellInput(id, 6, 1, '=1+1');
expect(s.cellFormat(id, 6, 1)).toEqual({});
});
it('setRangeFormat applies to a whole range and clears with falsy/auto', () => {
const s = fresh();
const id = tableId(s, 'Umsatz');
@@ -728,6 +761,15 @@ describe('custom functions (document-level library)', () => {
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a name colliding with a built-in date/time function', () => {
const s = fresh();
for (const name of ['DATE', 'time', 'Today', 'NOW']) {
const result = s.addCustomFunction(name, ['x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
}
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a name colliding with another custom function (case-insensitive)', () => {
const s = fresh();
s.addCustomFunction('Marge', ['x'], 'x');
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { dateToSerial, timeSerial } from '../../engine';
import { formatValue } from '../format';
describe('formatValue: date/time', () => {
const day = dateToSerial(2026, 9, 2); // a Wednesday
const noon = timeSerial(12, 0, 0);
const dayAtNoon = day + noon;
it('renders a date per locale', () => {
expect(formatValue(day, 'de-DE', 'date')).toBe('02.09.2026');
expect(formatValue(day, 'en-US', 'date')).toBe('09/02/2026');
expect(formatValue(day, 'plain', 'date')).toBe('2026-09-02');
});
it('renders a time per locale', () => {
expect(formatValue(noon, 'de-DE', 'time')).toBe('12:00');
expect(formatValue(noon, 'plain', 'time')).toBe('12:00');
});
it('renders date + time together', () => {
expect(formatValue(dayAtNoon, 'de-DE', 'datetime')).toBe('02.09.2026 12:00');
expect(formatValue(dayAtNoon, 'plain', 'datetime')).toBe('2026-09-02 12:00');
});
it('empty cell / error still short-circuit before any date formatting', () => {
expect(formatValue(null, 'de-DE', 'date')).toBe('');
});
});
+11
View File
@@ -33,6 +33,7 @@ import {
declaredBlockNames,
findInvalidForSource,
blockAlwaysReturns,
parseDateTimeLiteral,
type CellValue,
type TableModel,
type Token,
@@ -960,6 +961,16 @@ function createStore() {
if (col >= table.cols) table.cols = col + 1;
if (row >= table.rows) table.rows = row + 1;
wb.setCell(tableId, col, row, trimmed);
// Typing a recognizable date/time literal auto-applies the matching
// display format, like Excel/Numbers -- but only while the cell has no
// explicit numFmt of its own yet, so this never overrides a deliberate
// choice (e.g. a cell the user already set to "Ohne Formatierung").
if (trimmed !== '' && !trimmed.startsWith('=')) {
const dt = parseDateTimeLiteral(trimmed);
if (dt && (cellFormat(tableId, col, row).numFmt ?? 'auto') === 'auto') {
setRangeFormat(tableId, { col, row }, { col, row }, { numFmt: dt.kind });
}
}
bump();
}
function clearRange(tableId: string, a: CellCoord, b: CellCoord) {
+35 -1
View File
@@ -1,4 +1,4 @@
import { isError, type CellValue } from '../engine';
import { isError, serialToUtcDate, type CellValue } from '../engine';
import type { CellNumFmt } from './types';
/**
@@ -42,6 +42,9 @@ export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
{ 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 {
@@ -58,6 +61,31 @@ function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFo
});
}
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,
@@ -87,6 +115,12 @@ export function formatValue(
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)
+4 -1
View File
@@ -23,7 +23,10 @@ export type CellNumFmt =
| 'dec1' // 1 decimal
| 'dec2' // 2 decimals
| 'pct0' // percent, 0 decimals (value 0.5 -> "50 %")
| 'pct2'; // percent, 2 decimals
| 'pct2' // percent, 2 decimals
| 'date' // e.g. 02.09.2026 -- value is a date serial, see engine/dates.ts
| 'time' // e.g. 14:30
| 'datetime'; // date + time
/** Explicit formatting for a single cell (all fields optional = inherit). */
export interface CellFormat {