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>
This commit is contained in:
wkh
2026-09-28 02:21:48 +02:00
co-authored by Claude Opus 5.5
parent 604794118b
commit c4d2712903
11 changed files with 620 additions and 21 deletions
+252 -1
View File
@@ -14,7 +14,19 @@
*/
import { FormulaError, isError, type CellValue } from './types';
import { dateToSerial, timeSerial, todaySerial, nowSerial, strictNum } from './dates';
import {
dateToSerial,
timeSerial,
todaySerial,
nowSerial,
strictNum,
serialParts,
isoWeekNumber,
endOfMonthSerial,
addMonthsSerial,
parseDateTimeLiteral,
type SerialParts,
} from './dates';
export type FunctionFn = (values: CellValue[]) => CellValue;
@@ -38,6 +50,63 @@ 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);
@@ -157,6 +226,171 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
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:
@@ -206,6 +440,23 @@ export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> =
{ 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; …)' },
];