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
+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`);
}