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