From e6cb18ab3cbcad48f8ce3c117fba21aae76f5a08 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 2 Sep 2026 23:24:39 +0200 Subject: [PATCH] Datum- & Zeit-Formatierung: Serial-Zahlen, Auto-Erkennung, DATE/TIME/TODAY/NOW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 55 ++++++++- src/engine/README.md | 59 ++++++++- src/engine/__tests__/dates.spec.ts | 120 +++++++++++++++++++ src/engine/__tests__/workbook.spec.ts | 62 +++++++++- src/engine/dates.ts | 164 ++++++++++++++++++++++++++ src/engine/functions.ts | 45 +++++++ src/engine/index.ts | 9 ++ src/engine/workbook.ts | 10 +- src/store/__tests__/document.spec.ts | 42 +++++++ src/store/__tests__/format.spec.ts | 29 +++++ src/store/document.ts | 11 ++ src/store/format.ts | 36 +++++- src/store/types.ts | 5 +- 13 files changed, 636 insertions(+), 11 deletions(-) create mode 100644 src/engine/__tests__/dates.spec.ts create mode 100644 src/engine/dates.ts create mode 100644 src/store/__tests__/format.spec.ts diff --git a/README.md b/README.md index a3c60e5..2c880c7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/engine/README.md b/src/engine/README.md index e44e773..4ed8357 100644 --- a/src/engine/README.md +++ b/src/engine/README.md @@ -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. diff --git a/src/engine/__tests__/dates.spec.ts b/src/engine/__tests__/dates.spec.ts new file mode 100644 index 0000000..efecd6f --- /dev/null +++ b/src/engine/__tests__/dates.spec.ts @@ -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(); + }); +}); diff --git a/src/engine/__tests__/workbook.spec.ts b/src/engine/__tests__/workbook.spec.ts index 5af0891..a4108c6 100644 --- a/src/engine/__tests__/workbook.spec.ts +++ b/src/engine/__tests__/workbook.spec.ts @@ -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); + }); +}); diff --git a/src/engine/dates.ts b/src/engine/dates.ts new file mode 100644 index 0000000..7b677d1 --- /dev/null +++ b/src/engine/dates.ts @@ -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`); +} diff --git a/src/engine/functions.ts b/src/engine/functions.ts index 83e9254..31a5aee 100644 --- a/src/engine/functions.ts +++ b/src/engine/functions.ts @@ -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 = { 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. diff --git a/src/engine/index.ts b/src/engine/index.ts index ed0be78..7d08b1e 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -33,6 +33,15 @@ export { literalValue, type TableModel, } from './workbook'; +export { + dateToSerial, + timeSerial, + serialToUtcDate, + todaySerial, + nowSerial, + parseDateTimeLiteral, + type DateLiteralKind, +} from './dates'; export { buildFunctionIndex, expandUserFunctions, diff --git a/src/engine/workbook.ts b/src/engine/workbook.ts index 7f8ec6a..209e977 100644 --- a/src/engine/workbook.ts +++ b/src/engine/workbook.ts @@ -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; } diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 7dcea4d..102cc96 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -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'); diff --git a/src/store/__tests__/format.spec.ts b/src/store/__tests__/format.spec.ts new file mode 100644 index 0000000..e630f68 --- /dev/null +++ b/src/store/__tests__/format.spec.ts @@ -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(''); + }); +}); diff --git a/src/store/document.ts b/src/store/document.ts index c04bda5..cf58662 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -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) { diff --git a/src/store/format.ts b/src/store/format.ts index 31f9eec..4e755cc 100644 --- a/src/store/format.ts +++ b/src/store/format.ts @@ -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) diff --git a/src/store/types.ts b/src/store/types.ts index 2e4783d..85e7099 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -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 {