Files
numolus/app/frontend/engine/dates.ts
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

233 lines
9.6 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));
}
/** A serial's calendar and clock fields. The time is rounded to the whole
* second first, so floating-point noise (`TIME(14;30;0)` summed from parts)
* never turns 14:30:00 into 14:29:59. `day` is the integer serial of the
* calendar day; `weekday` follows ISO 8601 (Monday = 1 ... Sunday = 7). */
export interface SerialParts {
day: number;
year: number;
month: number;
date: number;
weekday: number;
hour: number;
minute: number;
second: number;
}
export function serialParts(serial: number): SerialParts {
const totalSec = Math.round(serial * 86400);
const day = Math.floor(totalSec / 86400);
const sec = totalSec - day * 86400;
const d = new Date(EPOCH_MS + day * MS_PER_DAY);
return {
day,
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
date: d.getUTCDate(),
weekday: ((d.getUTCDay() + 6) % 7) + 1,
hour: Math.floor(sec / 3600),
minute: Math.floor((sec % 3600) / 60),
second: sec % 60,
};
}
/** ISO 8601 week number (weeks start Monday; week 1 is the one containing
* the year's first Thursday) of a serial's calendar day. */
export function isoWeekNumber(serial: number): number {
const { day, weekday } = serialParts(serial);
const thursday = day - weekday + 4; // the Thursday of this ISO week decides its year
const { year } = serialParts(thursday);
const jan1 = Math.round(dateToSerial(year, 1, 1));
return Math.floor((thursday - jan1) / 7) + 1;
}
/** Last calendar day of the month `months` after the serial's month. */
export function endOfMonthSerial(serial: number, months: number): number {
const { year, month } = serialParts(serial);
return Math.round(dateToSerial(year, month + months + 1, 0));
}
/** Same day `months` later, clamped to that month's last day (31.01. + 1
* month = 28./29.02.), like Excel's EDATE. */
export function addMonthsSerial(serial: number, months: number): number {
const { year, month, date } = serialParts(serial);
const last = serialParts(endOfMonthSerial(serial, months)).date;
return Math.round(dateToSerial(year, month + months, Math.min(date, last)));
}
/** 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,
);
}
/** `'duration'` is a time span typed with 24 or more hours (`37:30`) --
* shown as elapsed hours instead of wrapping to a time of day. */
export type DateLiteralKind = 'date' | 'time' | 'datetime' | 'duration';
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}))?$/;
const DURATION_RE = /^(\d{1,6}):(\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 (24+ hours: a
* duration) and date + time 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 (mi > 59 || s > 59) return null;
if (h <= 23) return { kind: 'time', serial: timeSerial(h, mi, s) };
}
// 24 hours or more can't be a time of day -- it's an elapsed time
m = DURATION_RE.exec(t);
if (m) {
const h = +m[1];
const mi = +m[2];
const s = m[3] ? +m[3] : 0;
if (mi > 59 || s > 59) return null;
return { kind: 'duration', serial: (h * 3600 + mi * 60 + s) / 86400 };
}
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`);
}