Files
numolus/src/engine/functions.ts
T
wkhandClaude Opus 5.5 c4d2712903 Durations past 24 hours and the basic date/time functions
- New cell format "Dauer" ([h]:mm): hours keep counting past 24 (37:30).
  A formula left on "Automatisch" whose numeric inputs are all times or
  durations (=SUM over working times, =end-start) displays as a duration.
  Typing 24 or more hours (37:30) enters a duration.
- "Datum + Zeit" shows a bare time of day (< 1 day) without the epoch
  date 30.12.1899.
- New functions: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, WEEKDAY,
  ISOWEEKNUM, WEEKNUM, EDATE, EOMONTH, DAYS, DATEDIF, NETWORKDAYS,
  WORKDAY, DATEVALUE, TIMEVALUE. Where Excel defaults to US conventions,
  ISO 8601 is the default: weeks start Monday (WEEKDAY -> Monday = 1),
  WEEKNUM is the ISO week. Excel's other types stay available.
- Command reference and README document all of it; the README test
  count is updated to 553.

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

470 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Built-in functions: a plain table { NAME -> fn(values) }.
*
* By the time a function runs, the evaluator has already:
* - resolved every range argument into a flat list of cell values,
* - flattened scalar arguments into that same list,
* - checked for errors among the values (an error short-circuits and is
* returned instead of calling the function).
*
* So a function only ever sees `(number | string | null)[]`. Each function
* decides for itself how to treat text and empty cells. Per spec:
* - empty cell -> ignored by every aggregate, never counted by COUNT
* - text -> ignored by the numeric aggregates
*/
import { FormulaError, isError, type CellValue } from './types';
import {
dateToSerial,
timeSerial,
todaySerial,
nowSerial,
strictNum,
serialParts,
isoWeekNumber,
endOfMonthSerial,
addMonthsSerial,
parseDateTimeLiteral,
type SerialParts,
} from './dates';
export type FunctionFn = (values: CellValue[]) => CellValue;
/** Keep only real numbers (finite). */
function nums(values: CellValue[]): number[] {
const out: number[] = [];
for (const v of values) {
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
}
return out;
}
/** The one truthy convention used everywhere in this engine a condition is
* needed (IF's condition -- both the formula function and its real
* short-circuit implementation in evaluator.ts's evalIf() --, a "Mit
* Schleife" block's IF statement, and AND/OR/NOT below): a number is
* truthy unless it's exactly `0`; text is truthy unless empty; an empty
* cell (`null`) is never truthy. Exported so evaluator.ts can reuse the
* exact same rule instead of duplicating it. */
export function isTruthy(v: CellValue): boolean {
return typeof v === 'number' ? v !== 0 : v !== null && v !== '';
}
/** Check the argument count and coerce every argument to a number (see
* `strictNum`) -- the shared prologue of the numeric date functions. An
* optional argument left out (or an empty `;;` slot) comes back as
* `undefined`. */
function numArgs(
name: string,
values: CellValue[],
min: number,
max: number,
): [number, ...(number | undefined)[]] | FormulaError {
if (values.length < min || values.length > max) {
const range = min === max ? `${min}` : `${min}–${max}`;
return new FormulaError('#ERR', `${name} erwartet ${range} Argument${max === 1 ? '' : 'e'}`);
}
const out: Array<number | undefined> = [];
for (let i = 0; i < values.length; i++) {
if (i >= min && values[i] === null) {
out.push(undefined);
continue;
}
const n = strictNum(values[i]);
if (isError(n)) return n;
out.push(n);
}
return out as [number, ...(number | undefined)[]];
}
/** YEAR/MONTH/DAY/HOUR/MINUTE/SECOND: one serial in, one field out. */
function part(name: string, pick: (p: SerialParts) => number): FunctionFn {
return (values) => {
const a = numArgs(name, values, 1, 1);
if (isError(a)) return a;
return pick(serialParts(a[0]));
};
}
function holidaySet(values: CellValue[]): Set<number> {
const out = new Set<number>();
for (const v of values) if (typeof v === 'number') out.add(serialParts(v).day);
return out;
}
function isWorkday(day: number, holidays: Set<number>): boolean {
return serialParts(day).weekday <= 5 && !holidays.has(day);
}
/** DATEVALUE/TIMEVALUE's single argument: a recognizable date/time text, or
* a number (already a serial). */
function textDateArg(name: string, values: CellValue[]): number | FormulaError {
if (values.length !== 1) return new FormulaError('#ERR', `${name} erwartet 1 Argument`);
const v = values[0];
if (typeof v === 'number') return v;
const hit = typeof v === 'string' ? parseDateTimeLiteral(v) : null;
if (!hit) return new FormulaError('#VALUE!', `${name}: '${v ?? ''}' ist kein Datum/keine Uhrzeit`);
return hit.serial;
}
export const FUNCTIONS: Record<string, FunctionFn> = {
SUM(values) {
return nums(values).reduce((a, b) => a + b, 0);
},
AVG(values) {
const n = nums(values);
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
return n.reduce((a, b) => a + b, 0) / n.length;
},
MIN(values) {
const n = nums(values);
return n.length ? Math.min(...n) : 0;
},
MAX(values) {
const n = nums(values);
return n.length ? Math.max(...n) : 0;
},
COUNT(values) {
return nums(values).length;
},
COUNT2(values) {
return values.length;
},
// Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering
// as the `&` operator (toText() in evaluator.ts): a number prints plain
// (no locale formatting), an empty cell contributes ''.
CONCAT(values) {
return values.map((v) => (v === null ? '' : String(v))).join('');
},
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
// division when B1=0). This entry only exists so IF is a registered
// built-in for hasFunction()'s sake (the store's custom-function name
// check then correctly refuses to let a user shadow it) and as a
// defensive, best-effort fallback if this were ever reached directly
// (values are already-eagerly-computed here, so it can't short-circuit --
// see evaluator.ts's evalIf() for the real implementation).
IF(values) {
if (values.length !== 2 && values.length !== 3) {
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
}
const [cond, thenValue, elseValue] = values;
if (isError(cond)) return cond;
if (isTruthy(cond)) return thenValue;
return values.length === 3 ? elseValue : 0;
},
// Ordinary (non-short-circuit) functions -- like real Excel, EVERY
// argument is evaluated regardless of the others (so e.g.
// =AND(1=2; 1/0) is still #DIV/0!, not FALSE, exactly as in Excel) --
// no special-casing in evaluator.ts needed, unlike IF/XLOOKUP. A range
// argument flattens into one truthy check per cell, same as Excel.
AND(values) {
if (values.length === 0) return new FormulaError('#ERR', 'AND expects at least 1 argument');
return values.every(isTruthy) ? 1 : 0;
},
OR(values) {
if (values.length === 0) return new FormulaError('#ERR', 'OR expects at least 1 argument');
return values.some(isTruthy) ? 1 : 0;
},
NOT(values) {
if (values.length !== 1) return new FormulaError('#ERR', 'NOT expects exactly 1 argument');
return isTruthy(values[0]) ? 0 : 1;
},
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
// same as everywhere else in this engine; a non-numeric text argument is
// `#VALUE!`.
DATE(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'DATE expects 3 arguments (Jahr; Monat; Tag)');
}
const y = strictNum(values[0]);
if (isError(y)) return y;
const mo = strictNum(values[1]);
if (isError(mo)) return mo;
const d = strictNum(values[2]);
if (isError(d)) return d;
return dateToSerial(y, mo, d);
},
TIME(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'TIME expects 3 arguments (Stunde; Minute; Sekunde)');
}
const h = strictNum(values[0]);
if (isError(h)) return h;
const mi = strictNum(values[1]);
if (isError(mi)) return mi;
const s = strictNum(values[2]);
if (isError(s)) return s;
return timeSerial(h, mi, s);
},
// Not "volatile": this engine only recalculates on precedent change, so a
// cell holding =TODAY()/=NOW() refreshes when THAT cell is next re-edited
// or recalculated, not spontaneously when the clock ticks over (documented
// limitation, see README.md's "Bekannte Grenzen").
TODAY(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'TODAY erwartet keine Argumente');
return todaySerial();
},
NOW(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'NOW erwartet keine Argumente');
return nowSerial();
},
// ---- date/time parts & calendar arithmetic ----------------------------
// All take date serials (see dates.ts). Where Excel has a US-centric
// default, the ISO 8601 convention is the default here: weeks start on
// Monday (WEEKDAY -> 1 = Monday), week numbers are ISO weeks.
YEAR: part('YEAR', (p) => p.year),
MONTH: part('MONTH', (p) => p.month),
DAY: part('DAY', (p) => p.date),
HOUR: part('HOUR', (p) => p.hour),
MINUTE: part('MINUTE', (p) => p.minute),
SECOND: part('SECOND', (p) => p.second),
// WEEKDAY(Datum; [Typ]) -- Typ as in Excel: 1 = Sunday 1 ... Saturday 7,
// 2 = Monday 1 ... Sunday 7 (ISO, the default here), 3 = Monday 0 ...
// Sunday 6.
WEEKDAY(values) {
const a = numArgs('WEEKDAY', values, 1, 2);
if (isError(a)) return a;
const iso = serialParts(a[0]).weekday;
switch (a[1] ?? 2) {
case 1:
return (iso % 7) + 1;
case 2:
return iso;
case 3:
return iso - 1;
default:
return new FormulaError('#VALUE!', 'WEEKDAY: Typ muss 1, 2 oder 3 sein');
}
},
ISOWEEKNUM(values) {
const a = numArgs('ISOWEEKNUM', values, 1, 1);
if (isError(a)) return a;
return isoWeekNumber(a[0]);
},
// WEEKNUM(Datum; [Typ]) -- ISO week by default (Typ 21, as in Excel);
// Typ 1 = weeks start Sunday, 2 = weeks start Monday, both counting the
// week containing 1 January as week 1 (Excel's US-style numbering).
WEEKNUM(values) {
const a = numArgs('WEEKNUM', values, 1, 2);
if (isError(a)) return a;
const type = a[1] ?? 21;
if (type === 21) return isoWeekNumber(a[0]);
if (type !== 1 && type !== 2) return new FormulaError('#VALUE!', 'WEEKNUM: Typ muss 1, 2 oder 21 sein');
const p = serialParts(a[0]);
const jan1 = Math.round(dateToSerial(p.year, 1, 1));
const jan1Iso = serialParts(jan1).weekday; // 1 = Monday
const offset = type === 2 ? jan1Iso - 1 : jan1Iso % 7; // days since week start
return Math.floor((p.day - jan1 + offset) / 7) + 1;
},
EDATE(values) {
const a = numArgs('EDATE', values, 2, 2);
if (isError(a)) return a;
return addMonthsSerial(a[0], Math.trunc(a[1] ?? 0));
},
EOMONTH(values) {
const a = numArgs('EOMONTH', values, 2, 2);
if (isError(a)) return a;
return endOfMonthSerial(a[0], Math.trunc(a[1] ?? 0));
},
// DAYS(Ende; Start) -- whole calendar days between two dates.
DAYS(values) {
const a = numArgs('DAYS', values, 2, 2);
if (isError(a)) return a;
return serialParts(a[0]).day - serialParts(a[1] ?? 0).day;
},
// DATEDIF(Start; Ende; Einheit) -- "Y", "M", "D" complete years/months/
// days, "MD"/"YM"/"YD" the remainder ignoring months+years/years.
DATEDIF(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'DATEDIF erwartet 3 Argumente (Start; Ende; Einheit)');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const e0 = strictNum(values[1]);
if (isError(e0)) return e0;
const unit = typeof values[2] === 'string' ? values[2].trim().toUpperCase() : '';
const s = serialParts(s0);
const e = serialParts(e0);
if (s.day > e.day) return new FormulaError('#VALUE!', 'DATEDIF: Start liegt nach Ende');
let months = (e.year - s.year) * 12 + (e.month - s.month);
if (e.date < s.date) months -= 1;
switch (unit) {
case 'D':
return e.day - s.day;
case 'M':
return months;
case 'Y':
return Math.floor(months / 12);
case 'YM':
return months % 12;
case 'MD':
return e.day - addMonthsSerial(s.day, months);
case 'YD': {
const years = Math.floor(months / 12);
return e.day - addMonthsSerial(s.day, years * 12);
}
default:
return new FormulaError('#VALUE!', 'DATEDIF: Einheit muss "Y", "M", "D", "MD", "YM" oder "YD" sein');
}
},
// NETWORKDAYS(Start; Ende; [Feiertage…]) -- Monday-Friday days in the
// span, both ends included, minus any listed holiday on a weekday.
NETWORKDAYS(values) {
if (values.length < 2) {
return new FormulaError('#ERR', 'NETWORKDAYS erwartet mindestens 2 Argumente (Start; Ende; [Feiertage])');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const e0 = strictNum(values[1]);
if (isError(e0)) return e0;
const holidays = holidaySet(values.slice(2));
const a = serialParts(s0).day;
const b = serialParts(e0).day;
const [lo, hi] = a <= b ? [a, b] : [b, a];
if (hi - lo > 366 * 1000) return new FormulaError('#VALUE!', 'NETWORKDAYS: Zeitraum zu groß');
let n = 0;
for (let d = lo; d <= hi; d++) if (isWorkday(d, holidays)) n++;
return a <= b ? n : -n;
},
// WORKDAY(Start; Tage; [Feiertage…]) -- the date `Tage` working days
// (Monday-Friday, not a holiday) after (or, negative, before) Start.
WORKDAY(values) {
if (values.length < 2) {
return new FormulaError('#ERR', 'WORKDAY erwartet mindestens 2 Argumente (Start; Tage; [Feiertage])');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const n0 = strictNum(values[1]);
if (isError(n0)) return n0;
let left = Math.trunc(Math.abs(n0));
if (left > 366 * 1000) return new FormulaError('#VALUE!', 'WORKDAY: zu viele Tage');
const step = n0 < 0 ? -1 : 1;
const holidays = holidaySet(values.slice(2));
let d = serialParts(s0).day;
while (left > 0) {
d += step;
if (isWorkday(d, holidays)) left--;
}
return d;
},
// DATEVALUE("2026-09-02") / TIMEVALUE("14:30") -- the date resp. time part
// of a text in any format a cell entry is recognized in (ISO, 02.09.2026,
// 14:30, …). A number (a cell already holding a date) passes through.
DATEVALUE(values) {
const v = textDateArg('DATEVALUE', values);
if (isError(v)) return v;
return serialParts(v).day;
},
TIMEVALUE(values) {
const v = textDateArg('TIMEVALUE', values);
if (isError(v)) return v;
const p = serialParts(v);
return (p.hour * 3600 + p.minute * 60 + p.second) / 86400;
},
// XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
// [Vergleichsmodus]; [Suchmodus]) -- like IF, evaluator.ts special-cases
// this name *before* ever reaching FUNCTIONS. Two reasons, not just one:
// 1. WennNichtGefunden must only be evaluated when actually needed
// (short-circuit, same reasoning as IF's branches).
// 2. By the time a function gets here, EVERY argument -- including
// Suchbereich and Rückgabebereich, both ranges -- has already been
// flattened into ONE combined array (see the module comment above).
// That destroys the boundary between the two ranges, so there is no
// way to even attempt a best-effort fallback here: unlike IF's
// 2-3 scalar arguments, XLOOKUP's array arguments can't be recovered
// from a flat `values` list of unknown split point. This entry exists
// purely so XLOOKUP is a registered built-in (hasFunction() /
// name-reservation) -- see evaluator.ts's evalXLookup() for the real
// implementation. Reaching this in practice would be an engine bug.
XLOOKUP() {
return new FormulaError('#ERR', 'XLOOKUP must be evaluated by evaluator.ts, not called directly');
},
};
// AVERAGE is an alias of AVG.
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
// CONCATENATE is Excel's classic name for the same thing as CONCAT.
FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT;
/** One-line signature hint per built-in, for the formula-autocomplete
* dropdown (see store/document.ts's `functionSuggestions()` and
* src/composables/useFormulaAutocomplete.ts). Hand-maintained -- kept
* honest by a test asserting its names exactly match `FUNCTIONS`'s keys,
* so a future new built-in without a matching entry here fails loudly
* instead of just silently missing from the dropdown. */
export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = [
{ name: 'SUM', hint: 'SUM(Zahl1; Zahl2; …)' },
{ name: 'AVG', hint: 'AVG(Zahl1; Zahl2; …)' },
{ name: 'AVERAGE', hint: 'AVERAGE(Zahl1; Zahl2; …)' },
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
{ name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' },
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
{ name: 'AND', hint: 'AND(Bedingung1; Bedingung2; …)' },
{ name: 'OR', hint: 'OR(Bedingung1; Bedingung2; …)' },
{ name: 'NOT', hint: 'NOT(Bedingung)' },
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
{ name: 'TODAY', hint: 'TODAY()' },
{ name: 'NOW', hint: 'NOW()' },
{ name: 'YEAR', hint: 'YEAR(Datum)' },
{ name: 'MONTH', hint: 'MONTH(Datum)' },
{ name: 'DAY', hint: 'DAY(Datum)' },
{ name: 'HOUR', hint: 'HOUR(Zeit)' },
{ name: 'MINUTE', hint: 'MINUTE(Zeit)' },
{ name: 'SECOND', hint: 'SECOND(Zeit)' },
{ name: 'WEEKDAY', hint: 'WEEKDAY(Datum; Typ?)' },
{ name: 'ISOWEEKNUM', hint: 'ISOWEEKNUM(Datum)' },
{ name: 'WEEKNUM', hint: 'WEEKNUM(Datum; Typ?)' },
{ name: 'EDATE', hint: 'EDATE(Datum; Monate)' },
{ name: 'EOMONTH', hint: 'EOMONTH(Datum; Monate)' },
{ name: 'DAYS', hint: 'DAYS(Ende; Start)' },
{ name: 'DATEDIF', hint: 'DATEDIF(Start; Ende; Einheit)' },
{ name: 'NETWORKDAYS', hint: 'NETWORKDAYS(Start; Ende; Feiertage?)' },
{ name: 'WORKDAY', hint: 'WORKDAY(Start; Tage; Feiertage?)' },
{ name: 'DATEVALUE', hint: 'DATEVALUE(Text)' },
{ name: 'TIMEVALUE', hint: 'TIMEVALUE(Text)' },
{ name: 'XLOOKUP', hint: 'XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)' },
];
export function hasFunction(name: string): boolean {
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
}
export function getFunction(name: string): FunctionFn | undefined {
return FUNCTIONS[name.toUpperCase()];
}