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:
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FUNCTIONS } from '../functions';
|
||||
import { dateToSerial, timeSerial } from '../dates';
|
||||
import { isError, type CellValue } from '../types';
|
||||
|
||||
const call = (name: string, ...args: CellValue[]) => FUNCTIONS[name](args);
|
||||
const d = (y: number, m: number, day: number) => dateToSerial(y, m, day);
|
||||
|
||||
describe('date parts', () => {
|
||||
const t = dateToSerial(2026, 9, 2, 14, 30, 15);
|
||||
|
||||
it('YEAR / MONTH / DAY / HOUR / MINUTE / SECOND', () => {
|
||||
expect(call('YEAR', t)).toBe(2026);
|
||||
expect(call('MONTH', t)).toBe(9);
|
||||
expect(call('DAY', t)).toBe(2);
|
||||
expect(call('HOUR', t)).toBe(14);
|
||||
expect(call('MINUTE', t)).toBe(30);
|
||||
expect(call('SECOND', t)).toBe(15);
|
||||
});
|
||||
|
||||
it('rounds to the whole second, so float noise never shows as :59', () => {
|
||||
const noisy = timeSerial(14, 29, 59) + 1 / 86400;
|
||||
expect(call('HOUR', noisy)).toBe(14);
|
||||
expect(call('MINUTE', noisy)).toBe(30);
|
||||
expect(call('SECOND', noisy)).toBe(0);
|
||||
});
|
||||
|
||||
it('checks argument count and type', () => {
|
||||
expect(isError(call('YEAR'))).toBe(true);
|
||||
expect(isError(call('YEAR', 1, 2))).toBe(true);
|
||||
expect(isError(call('YEAR', 'kein Datum'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEEKDAY (ISO default: Monday = 1)', () => {
|
||||
const wed = d(2026, 9, 2);
|
||||
it('defaults to ISO numbering', () => {
|
||||
expect(call('WEEKDAY', wed)).toBe(3);
|
||||
expect(call('WEEKDAY', d(2026, 9, 6))).toBe(7); // Sunday
|
||||
});
|
||||
it('supports Excel types 1, 2, 3', () => {
|
||||
expect(call('WEEKDAY', wed, 1)).toBe(4); // Sunday = 1
|
||||
expect(call('WEEKDAY', wed, 2)).toBe(3);
|
||||
expect(call('WEEKDAY', wed, 3)).toBe(2); // Monday = 0
|
||||
expect(isError(call('WEEKDAY', wed, 7))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ISO week numbers', () => {
|
||||
it('handles the year boundaries', () => {
|
||||
expect(call('ISOWEEKNUM', d(2026, 1, 1))).toBe(1); // Thursday
|
||||
expect(call('ISOWEEKNUM', d(2027, 1, 1))).toBe(53); // Friday -> last week of 2026
|
||||
expect(call('ISOWEEKNUM', d(2021, 1, 3))).toBe(53); // Sunday -> week 53 of 2020
|
||||
expect(call('ISOWEEKNUM', d(2024, 12, 30))).toBe(1); // Monday -> week 1 of 2025
|
||||
expect(call('ISOWEEKNUM', d(2026, 9, 2))).toBe(36);
|
||||
});
|
||||
it('WEEKNUM is ISO by default, US-style with type 1/2', () => {
|
||||
expect(call('WEEKNUM', d(2021, 1, 3))).toBe(53);
|
||||
expect(call('WEEKNUM', d(2026, 1, 3), 1)).toBe(1); // Saturday, same week as 1 Jan
|
||||
expect(call('WEEKNUM', d(2026, 1, 4), 1)).toBe(2); // Sunday starts week 2
|
||||
expect(call('WEEKNUM', d(2026, 1, 4), 2)).toBe(1); // ... but not when weeks start Monday
|
||||
expect(call('WEEKNUM', d(2026, 1, 5), 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('month arithmetic', () => {
|
||||
it('EDATE clamps to the end of a shorter month', () => {
|
||||
expect(call('EDATE', d(2026, 1, 31), 1)).toBe(d(2026, 2, 28));
|
||||
expect(call('EDATE', d(2024, 1, 31), 1)).toBe(d(2024, 2, 29));
|
||||
expect(call('EDATE', d(2026, 3, 31), -1)).toBe(d(2026, 2, 28));
|
||||
expect(call('EDATE', d(2026, 9, 2), 12)).toBe(d(2027, 9, 2));
|
||||
});
|
||||
it('EOMONTH', () => {
|
||||
expect(call('EOMONTH', d(2026, 9, 2), 0)).toBe(d(2026, 9, 30));
|
||||
expect(call('EOMONTH', d(2026, 9, 2), -1)).toBe(d(2026, 8, 31));
|
||||
expect(call('EOMONTH', d(2026, 1, 15), 1)).toBe(d(2026, 2, 28));
|
||||
});
|
||||
});
|
||||
|
||||
describe('differences', () => {
|
||||
it('DAYS counts whole calendar days, ignoring the time', () => {
|
||||
expect(call('DAYS', d(2026, 12, 31), d(2026, 1, 1))).toBe(364);
|
||||
expect(call('DAYS', d(2026, 1, 2) + 0.9, d(2026, 1, 1) + 0.1)).toBe(1);
|
||||
});
|
||||
it('DATEDIF in every unit', () => {
|
||||
const s = d(2020, 2, 29);
|
||||
const e = d(2026, 9, 2);
|
||||
expect(call('DATEDIF', s, e, 'Y')).toBe(6);
|
||||
expect(call('DATEDIF', s, e, 'M')).toBe(78);
|
||||
expect(call('DATEDIF', s, e, 'D')).toBe(e - s);
|
||||
expect(call('DATEDIF', s, e, 'YM')).toBe(6);
|
||||
expect(call('DATEDIF', s, e, 'MD')).toBe(4); // 29.08. -> 02.09.
|
||||
expect(call('DATEDIF', s, e, 'yd')).toBe(d(2026, 9, 2) - d(2026, 2, 28));
|
||||
expect(isError(call('DATEDIF', e, s, 'D'))).toBe(true);
|
||||
expect(isError(call('DATEDIF', s, e, 'W'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('working days (Monday-Friday)', () => {
|
||||
it('NETWORKDAYS counts both ends and skips holidays on weekdays', () => {
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30))).toBe(22);
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 15))).toBe(21);
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 5))).toBe(22); // Saturday
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 30), d(2026, 9, 1))).toBe(-22);
|
||||
});
|
||||
it('WORKDAY steps over weekends and holidays, both directions', () => {
|
||||
expect(call('WORKDAY', d(2026, 9, 4), 1)).toBe(d(2026, 9, 7)); // Fri -> Mon
|
||||
expect(call('WORKDAY', d(2026, 9, 7), -1)).toBe(d(2026, 9, 4));
|
||||
expect(call('WORKDAY', d(2026, 9, 4), 1, d(2026, 9, 7))).toBe(d(2026, 9, 8));
|
||||
expect(call('WORKDAY', d(2026, 9, 2), 0)).toBe(d(2026, 9, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DATEVALUE / TIMEVALUE', () => {
|
||||
it('read the same formats a cell entry is recognized in', () => {
|
||||
expect(call('DATEVALUE', '2026-09-02')).toBe(d(2026, 9, 2));
|
||||
expect(call('DATEVALUE', '02.09.2026 14:30')).toBe(d(2026, 9, 2));
|
||||
expect(call('TIMEVALUE', '14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
|
||||
expect(call('TIMEVALUE', '2026-09-02 14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
|
||||
});
|
||||
it('pass a number through and reject other text', () => {
|
||||
expect(call('DATEVALUE', d(2026, 9, 2) + 0.5)).toBe(d(2026, 9, 2));
|
||||
expect(isError(call('DATEVALUE', 'hallo'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -107,9 +107,16 @@ describe('parseDateTimeLiteral', () => {
|
||||
expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
|
||||
});
|
||||
|
||||
it('rejects an out-of-range time', () => {
|
||||
expect(parseDateTimeLiteral('25:00')).toBeNull();
|
||||
it('rejects an out-of-range minute/second', () => {
|
||||
expect(parseDateTimeLiteral('12:60')).toBeNull();
|
||||
expect(parseDateTimeLiteral('37:30:60')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads 24 hours or more as a duration, not a time of day', () => {
|
||||
expect(parseDateTimeLiteral('25:00')).toEqual({ kind: 'duration', serial: 25 / 24 });
|
||||
expect(parseDateTimeLiteral('37:30')).toEqual({ kind: 'duration', serial: 37.5 / 24 });
|
||||
expect(parseDateTimeLiteral('100:00:30')!.serial).toBeCloseTo((100 * 3600 + 30) / 86400, 12);
|
||||
expect(parseDateTimeLiteral('23:59')!.kind).toBe('time');
|
||||
});
|
||||
|
||||
it('does not misfire on plain numbers or arbitrary text', () => {
|
||||
|
||||
+72
-4
@@ -38,6 +38,61 @@ 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 {
|
||||
@@ -64,13 +119,16 @@ export function nowSerial(): number {
|
||||
);
|
||||
}
|
||||
|
||||
export type DateLiteralKind = 'date' | 'time' | 'datetime';
|
||||
/** `'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. */
|
||||
@@ -104,7 +162,8 @@ function strictDateSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0
|
||||
*
|
||||
* 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
|
||||
* `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
|
||||
@@ -141,8 +200,17 @@ export function parseDateTimeLiteral(
|
||||
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) };
|
||||
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;
|
||||
}
|
||||
|
||||
+252
-1
@@ -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; …)' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user