Locale-unabhängiges ISO-Datumsformat als Zellformat
Neues CellNumFmt 'dateiso' im Zahlenformat-Dropdown ("Datum (ISO) ·
2026-09-02"): zeigt ein Datum immer als YYYY-MM-DD, unabhängig von der
aktiven Locale (Zelle/Blatt/Dokument) -- anders als "Datum", das der
jeweiligen Locale folgt. Nützlich für export-nahe/sortierbare
Datumsspalten.
- store/types.ts: CellNumFmt um 'dateiso' erweitert.
- store/format.ts: die manuelle YYYY-MM-DD-Formatierung, die bisher nur
für das 'plain'-Pseudo-Locale galt, in isoDateString() extrahiert und
von einem neuen 'dateiso'-Fall in formatValue() wiederverwendet.
NUM_FORMATS bekam den neuen Eintrag -- das Zahlenformat-Dropdown
(SettingsPanel.vue) und die JSON-Validierung (isCellNumFmt) sind
bereits generisch über NUM_FORMATS, keine Änderung nötig.
Tests: formatValue mit 'dateiso' über mehrere Locales (inkl. 'plain').
297 -> 298 Tests, alle grün. vue-tsc/eslint/build sauber. Live per
Playwright verifiziert: Zelle auf "Datum (ISO)" umgestellt zeigt
2026-09-02 und bleibt das auch nach Wechsel der Dokument-Locale auf
en-US, während eine andere Zelle ohne dieses Format der Locale folgt
(09/02/2026).
READMEs aktualisiert (Zahlenformat-Liste, "Datum & Zeit"-Abschnitt in
engine/README.md, Testzahl).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite-Devserver
|
npm run dev # Vite-Devserver
|
||||||
npm run test # Vitest-Suite (Engine + Store + IO), 297 Tests
|
npm run test # Vitest-Suite (Engine + Store + IO), 298 Tests
|
||||||
npm run build # Typecheck + Produktionsbuild
|
npm run build # Typecheck + Produktionsbuild
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
```
|
```
|
||||||
@@ -264,7 +264,8 @@ Inspector unter **Objekt · Auswahl**: horizontale **Ausrichtung**
|
|||||||
(links/mitte/rechts),
|
(links/mitte/rechts),
|
||||||
**Zahlenformat** (`Automatisch`, `Ohne`, Ganzzahl, 1/2 Nachkommastellen,
|
**Zahlenformat** (`Automatisch`, `Ohne`, Ganzzahl, 1/2 Nachkommastellen,
|
||||||
**Prozent** 0/2 Stellen, **Datum**/**Zeit**/**Datum + Zeit** — kombiniert mit
|
**Prozent** 0/2 Stellen, **Datum**/**Zeit**/**Datum + Zeit** — kombiniert mit
|
||||||
der Locale unten), **Zell-Textfarbe**,
|
der Locale unten — sowie **Datum (ISO)**, das immer `JJJJ-MM-TT` zeigt,
|
||||||
|
unabhängig von der Locale), **Zell-Textfarbe**,
|
||||||
**Zell-Füllfarbe**, **Fett**/**Kursiv**, „Formatierung entfernen". Auch exakte
|
**Zell-Füllfarbe**, **Fett**/**Kursiv**, „Formatierung entfernen". Auch exakte
|
||||||
**Spaltenbreite** und **Zeilenhöhe** der Fokuszelle.
|
**Spaltenbreite** und **Zeilenhöhe** der Fokuszelle.
|
||||||
|
|
||||||
@@ -440,6 +441,12 @@ ohne Zusatzaufwand: `=fällig - rechnungsdatum` zählt Tage, `=rechnungsdatum
|
|||||||
`NOW()` liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse
|
`NOW()` liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse
|
||||||
bekommen **kein** automatisches Zellformat (wie bei jeder anderen Formel
|
bekommen **kein** automatisches Zellformat (wie bei jeder anderen Formel
|
||||||
auch) — das Zahlenformat-Dropdown auf „Datum"/„Zeit"/„Datum + Zeit" stellen.
|
auch) — das Zahlenformat-Dropdown auf „Datum"/„Zeit"/„Datum + Zeit" stellen.
|
||||||
|
|
||||||
|
Das Zahlenformat **„Datum (ISO)"** zeigt ein Datum immer als `JJJJ-MM-TT`
|
||||||
|
(`2026-09-02`) — unabhängig von der aktiven Locale (Blatt/Dokument oder
|
||||||
|
Zelle), anders als „Datum", das der jeweiligen Locale folgt (`02.09.2026`
|
||||||
|
bei de-DE, `09/02/2026` bei en-US). Nützlich für export-nahe oder
|
||||||
|
sortierbare Datumsspalten.
|
||||||
`TODAY`/`NOW` sind **nicht volatil**: diese Engine rechnet rein
|
`TODAY`/`NOW` sind **nicht volatil**: diese Engine rechnet rein
|
||||||
vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()`
|
vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()`
|
||||||
aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von
|
aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von
|
||||||
@@ -561,7 +568,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 297 Tests:
|
`npm run test` — Vitest, 298 Tests:
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||||
@@ -582,9 +589,9 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
Schrägstrich-Daten und ungültigen/rollierten Daten; `literalValue` wandelt
|
Schrägstrich-Daten und ungültigen/rollierten Daten; `literalValue` wandelt
|
||||||
eine erkannte Eingabe in ihren Serial um; `DATE`/`TIME`/`TODAY`/`NOW` als
|
eine erkannte Eingabe in ihren Serial um; `DATE`/`TIME`/`TODAY`/`NOW` als
|
||||||
Formel (Basisfälle, Arity-/Typ-Fehler, Datums-Arithmetik); `formatValue`
|
Formel (Basisfälle, Arity-/Typ-Fehler, Datums-Arithmetik); `formatValue`
|
||||||
über mehrere Locales inkl. `plain`; Store: Auto-Format beim Eintippen
|
über mehrere Locales inkl. `plain` und das locale-unabhängige „Datum
|
||||||
(überschreibt nie ein bereits gesetztes Format), Namenskollision mit den
|
(ISO)"; Store: Auto-Format beim Eintippen (überschreibt nie ein bereits
|
||||||
neuen eingebauten Funktionen.
|
gesetztes Format), Namenskollision mit den neuen eingebauten Funktionen.
|
||||||
- **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus.
|
- **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus.
|
||||||
- **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch
|
- **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch
|
||||||
die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt
|
die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt
|
||||||
|
|||||||
@@ -364,7 +364,11 @@ eine Zahl ist, funktionieren `+`/`-`/Vergleiche darauf automatisch — keine
|
|||||||
manuelle, locale-neutrale `YYYY-MM-DD`/`HH:MM`-Formatierung (bewusst nicht
|
manuelle, locale-neutrale `YYYY-MM-DD`/`HH:MM`-Formatierung (bewusst nicht
|
||||||
`Intl` mit `undefined`-Locale, das würde lautlos die Host-Locale des
|
`Intl` mit `undefined`-Locale, das würde lautlos die Host-Locale des
|
||||||
Browsers durchsickern lassen). Formel-Ergebnisse bekommen **kein**
|
Browsers durchsickern lassen). Formel-Ergebnisse bekommen **kein**
|
||||||
automatisches Format — wie jedes andere Formelergebnis auch.
|
automatisches Format — wie jedes andere Formelergebnis auch. Ein viertes
|
||||||
|
`CellNumFmt` — `dateiso` — rendert dasselbe `YYYY-MM-DD` **unabhängig von
|
||||||
|
der Locale** (nutzt intern dieselbe Formatier-Funktion wie `'plain'`s
|
||||||
|
Datumsanteil), für export-nahe/sortierbare Datumsspalten, die sich nicht
|
||||||
|
mit der Blatt-/Dokument-Locale mitändern sollen.
|
||||||
- **Auto-Format beim Eintippen** (`store/document.ts`s `setCellInput`) — eine
|
- **Auto-Format beim Eintippen** (`store/document.ts`s `setCellInput`) — eine
|
||||||
erkannte Literal-Eingabe (nicht `=`-Formeln) setzt automatisch das
|
erkannte Literal-Eingabe (nicht `=`-Formeln) setzt automatisch das
|
||||||
passende `numFmt`, aber **nur** solange die Zelle noch `'auto'` ist; ein
|
passende `numFmt`, aber **nur** solange die Zelle noch `'auto'` ist; ein
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ describe('formatValue: date/time', () => {
|
|||||||
it('empty cell / error still short-circuit before any date formatting', () => {
|
it('empty cell / error still short-circuit before any date formatting', () => {
|
||||||
expect(formatValue(null, 'de-DE', 'date')).toBe('');
|
expect(formatValue(null, 'de-DE', 'date')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('"dateiso" renders YYYY-MM-DD regardless of the active locale', () => {
|
||||||
|
expect(formatValue(day, 'de-DE', 'dateiso')).toBe('2026-09-02');
|
||||||
|
expect(formatValue(day, 'en-US', 'dateiso')).toBe('2026-09-02');
|
||||||
|
expect(formatValue(day, 'fr-FR', 'dateiso')).toBe('2026-09-02');
|
||||||
|
expect(formatValue(day, 'plain', 'dateiso')).toBe('2026-09-02');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-6
@@ -43,6 +43,7 @@ export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
|
|||||||
{ id: 'pct0', label: 'Prozent · 12 %' },
|
{ id: 'pct0', label: 'Prozent · 12 %' },
|
||||||
{ id: 'pct2', label: 'Prozent · 12,34 %' },
|
{ id: 'pct2', label: 'Prozent · 12,34 %' },
|
||||||
{ id: 'date', label: 'Datum · 02.09.2026' },
|
{ id: 'date', label: 'Datum · 02.09.2026' },
|
||||||
|
{ id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
|
||||||
{ id: 'time', label: 'Zeit · 14:30' },
|
{ id: 'time', label: 'Zeit · 14:30' },
|
||||||
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
|
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
|
||||||
];
|
];
|
||||||
@@ -63,13 +64,18 @@ function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFo
|
|||||||
|
|
||||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
/** `'plain'` renders a fixed, locale-neutral ISO-ish shape by hand -- NOT
|
/** `YYYY-MM-DD`, hand-written rather than via `Intl` -- there's no locale
|
||||||
* via `Intl` with an `undefined` locale, which would silently fall back to
|
* that reliably reproduces ISO 8601, and this is also what the `'plain'`
|
||||||
* the *browser's* locale instead of staying neutral. */
|
* pseudo-locale uses for a date (an `Intl` call with an `undefined` locale
|
||||||
|
* would silently fall back to the *browser's* locale instead of staying
|
||||||
|
* neutral). Shared by the locale-independent `'dateiso'` cell format and
|
||||||
|
* by `formatDatePart`'s `'plain'` branch below. */
|
||||||
|
function isoDateString(d: Date): string {
|
||||||
|
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDatePart(d: Date, locale: NumberLocale): string {
|
function formatDatePart(d: Date, locale: NumberLocale): string {
|
||||||
if (locale === 'plain') {
|
if (locale === 'plain') return isoDateString(d);
|
||||||
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat(locale, {
|
return new Intl.DateTimeFormat(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -117,6 +123,8 @@ export function formatValue(
|
|||||||
}).format(n);
|
}).format(n);
|
||||||
case 'date':
|
case 'date':
|
||||||
return formatDatePart(serialToUtcDate(n), locale);
|
return formatDatePart(serialToUtcDate(n), locale);
|
||||||
|
case 'dateiso':
|
||||||
|
return isoDateString(serialToUtcDate(n)); // same regardless of locale
|
||||||
case 'time':
|
case 'time':
|
||||||
return formatTimePart(serialToUtcDate(n), locale);
|
return formatTimePart(serialToUtcDate(n), locale);
|
||||||
case 'datetime':
|
case 'datetime':
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type CellNumFmt =
|
|||||||
| 'pct0' // percent, 0 decimals (value 0.5 -> "50 %")
|
| '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
|
| 'date' // e.g. 02.09.2026 -- value is a date serial, see engine/dates.ts
|
||||||
|
| 'dateiso' // e.g. 2026-09-02 -- ISO 8601, same regardless of locale
|
||||||
| 'time' // e.g. 14:30
|
| 'time' // e.g. 14:30
|
||||||
| 'datetime'; // date + time
|
| 'datetime'; // date + time
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user