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>
165 lines
7.0 KiB
TypeScript
165 lines
7.0 KiB
TypeScript
/**
|
|
* 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`);
|
|
}
|