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
+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 {