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
+34
View File
@@ -435,6 +435,23 @@ const FUNCTION_DESCRIPTIONS: Record<string, string> = {
TODAY: 'Heutiges Datum.',
NOW: 'Aktuelles Datum mit Uhrzeit.',
XLOOKUP: 'Sucht einen Wert und gibt den passenden Eintrag aus einem zweiten Bereich zurück.',
YEAR: 'Jahr eines Datums.',
MONTH: 'Monat eines Datums (1–12).',
DAY: 'Tag des Monats (1–31).',
HOUR: 'Stunde einer Uhrzeit (0–23).',
MINUTE: 'Minute einer Uhrzeit (0–59).',
SECOND: 'Sekunde einer Uhrzeit (0–59).',
WEEKDAY: 'Wochentag nach ISO: Montag = 1 … Sonntag = 7. Typ 1: Sonntag = 1, Typ 3: Montag = 0.',
ISOWEEKNUM: 'Kalenderwoche nach ISO 8601 (Woche 1 enthält den ersten Donnerstag des Jahres).',
WEEKNUM: 'Kalenderwoche, standardmäßig nach ISO. Typ 1/2: US-Zählung mit Wochenbeginn Sonntag/Montag.',
EDATE: 'Gleicher Tag einige Monate später (negativ: früher), am Monatsende gekürzt.',
EOMONTH: 'Letzter Tag des Monats, einige Monate vor oder nach dem Datum.',
DAYS: 'Anzahl Kalendertage zwischen zwei Daten.',
DATEDIF: 'Abstand zweier Daten in "Y" Jahren, "M" Monaten, "D" Tagen oder als Rest "YM", "MD", "YD".',
NETWORKDAYS: 'Arbeitstage (Mo–Fr) zwischen zwei Daten, beide eingeschlossen, ohne Feiertage.',
WORKDAY: 'Datum nach einer Anzahl Arbeitstage (Mo–Fr), Feiertage werden übersprungen.',
DATEVALUE: 'Datum aus einem Text wie "2026-09-02" oder "02.09.2026".',
TIMEVALUE: 'Uhrzeit aus einem Text wie "14:30".',
};
/** German search terms for built-ins whose description doesn't already say it. */
@@ -451,6 +468,23 @@ const FUNCTION_KEYWORDS: Record<string, string> = {
TODAY: 'heute datum',
NOW: 'jetzt zeit',
XLOOKUP: 'suchen sverweis verweis nachschlagen',
YEAR: 'jahr datum',
MONTH: 'monat datum',
DAY: 'tag datum',
HOUR: 'stunde zeit uhrzeit',
MINUTE: 'minute zeit uhrzeit',
SECOND: 'sekunde zeit uhrzeit',
WEEKDAY: 'wochentag tag woche',
ISOWEEKNUM: 'kalenderwoche kw woche',
WEEKNUM: 'kalenderwoche kw woche',
EDATE: 'monat addieren datum',
EOMONTH: 'monatsende ultimo',
DAYS: 'tage differenz abstand',
DATEDIF: 'differenz abstand alter jahre monate tage',
NETWORKDAYS: 'arbeitstage werktage feiertage',
WORKDAY: 'arbeitstag werktag feiertage frist',
DATEVALUE: 'datum text umwandeln',
TIMEVALUE: 'zeit uhrzeit text umwandeln',
};
const FUNCTIONS: RefEntry[] = BUILTIN_SIGNATURES.map((s) => ({
+125
View File
@@ -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);
});
});
+9 -2
View File
@@ -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
View File
@@ -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
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; …)' },
];
+29
View File
@@ -133,6 +133,35 @@ describe('seed document acceptance (canvas model)', () => {
expect(s.canUndo.value).toBe(false);
});
it('a formula over times shows as a duration, past 24 hours', () => {
const s = fresh();
s.newDocument();
const t = tableId(s, 'Tabelle 1');
setCell(s, 'Tabelle 1', 'A1', '08:30');
setCell(s, 'Tabelle 1', 'A2', '09:15');
setCell(s, 'Tabelle 1', 'A3', '20:00');
setCell(s, 'Tabelle 1', 'A4', '=SUM(A1:A3)');
setCell(s, 'Tabelle 1', 'B1', '=A2-A1');
setCell(s, 'Tabelle 1', 'B2', '=A4+B1'); // chain: a sum of formulas
expect(s.cellDisplay(t, 0, 3)).toBe('37:45');
expect(s.cellDisplay(t, 1, 0)).toBe('00:45');
expect(s.cellDisplay(t, 1, 1)).toBe('38:30');
// an explicit format always wins over the inferred one
s.setRangeFormat(t, { col: 0, row: 3 }, { col: 0, row: 3 }, { numFmt: 'time' });
expect(s.cellDisplay(t, 0, 3)).toBe('13:45');
});
it('a typed 24+ hour time becomes a duration; dates are not durations', () => {
const s = fresh();
s.newDocument();
const t = tableId(s, 'Tabelle 1');
setCell(s, 'Tabelle 1', 'A1', '37:30');
expect(s.cellDisplay(t, 0, 0)).toBe('37:30');
setCell(s, 'Tabelle 1', 'C1', '2026-09-02');
setCell(s, 'Tabelle 1', 'C2', '=C1+30');
expect(s.cellDisplay(t, 2, 1)).not.toContain(':');
});
it('inserting a new table and referencing it across sheets works', () => {
const s = fresh();
const sheet2 = s.doc.sheets[1].id;
+12
View File
@@ -23,6 +23,18 @@ describe('formatValue: date/time', () => {
expect(formatValue(dayAtNoon, 'plain', 'datetime')).toBe('2026-09-02 12:00');
});
it('date + time shows only the time for a bare time of day (no 1899 date)', () => {
expect(formatValue(timeSerial(12, 23, 0), 'de-DE', 'datetime')).toBe('12:23');
});
it('"duration" keeps counting hours past 24', () => {
expect(formatValue(37.5 / 24, 'de-DE', 'duration')).toBe('37:30');
expect(formatValue(timeSerial(8, 5, 0), 'de-DE', 'duration')).toBe('08:05');
expect(formatValue((100 * 3600 + 30) / 86400, 'de-DE', 'duration')).toBe('100:00:30');
expect(formatValue(-1.5 / 24, 'de-DE', 'duration')).toBe('-01:30');
expect(formatValue(0, 'de-DE', 'duration')).toBe('00:00');
});
it('empty cell / error still short-circuit before any date formatting', () => {
expect(formatValue(null, 'de-DE', 'date')).toBe('');
});
+26 -5
View File
@@ -62,6 +62,7 @@ import type {
ButtonObject,
CellCoord,
CellFormat,
CellNumFmt,
CellGraphNode,
ChartObject,
CustomFunction,
@@ -1767,11 +1768,31 @@ function createStore() {
return wb.cellValue(tableId, col, row);
}
function cellDisplay(tableId: string, col: number, row: number): string {
return formatValue(
cellValue(tableId, col, row),
effectiveLocale(tableId),
tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto',
);
return formatValue(cellValue(tableId, col, row), effectiveLocale(tableId), displayNumFmt(tableId, col, row));
}
/** The cell's number format, or -- for a formula cell left on "auto" -- a
* format inferred from what it computes with: a formula whose numeric
* inputs are all times/durations (`=SUM(B2:B8)`, `=C2-B2` over clock
* times) shows as a duration, so a total of 37.5 hours reads `37:30`
* instead of wrapping to `13:30` or showing the raw day fraction. Walks
* formula chains (a sum of sums), bounded by `budget` cells visited. */
function displayNumFmt(
tableId: string,
col: number,
row: number,
budget = { left: 2000 },
): CellNumFmt {
const explicit = tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto';
if (explicit !== 'auto' || !cellInput(tableId, col, row).startsWith('=')) return explicit;
let sawTime = false;
for (const p of wb.graphPrecedents(tableId, col, row)) {
if (--budget.left < 0) return 'auto';
if (typeof wb.cellValue(p.tableId, p.col, p.row) !== 'number') continue;
const f = displayNumFmt(p.tableId, p.col, p.row, budget);
if (f !== 'time' && f !== 'duration') return 'auto';
sawTime = true;
}
return sawTime ? 'duration' : 'auto';
}
// ---- number-format locale (object -> sheet -> document cascade) ---
+18
View File
@@ -46,6 +46,7 @@ export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
{ id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
{ id: 'time', label: 'Zeit · 14:30' },
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
{ id: 'duration', label: 'Dauer · 37:30' },
];
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
export function isCellNumFmt(x: unknown): x is CellNumFmt {
@@ -92,6 +93,18 @@ function formatTimePart(d: Date, locale: NumberLocale): string {
}).format(d);
}
/** Elapsed time as `[h]:mm` -- hours keep counting past 24 (`37:30`), the
* way a sum of working times should read; seconds only when there are any.
* Locale-independent: `:` is the time separator in every supported locale. */
function formatDuration(n: number): string {
let sec = Math.round(Math.abs(n) * 86400);
const h = Math.floor(sec / 3600);
sec -= h * 3600;
const m = Math.floor(sec / 60);
const s = sec - m * 60;
return `${n < 0 && (h || m || s) ? '-' : ''}${pad2(h)}:${pad2(m)}${s ? `:${pad2(s)}` : ''}`;
}
/** Render a computed value for display (grid cell / status bar / chart). */
export function formatValue(
v: CellValue,
@@ -128,7 +141,12 @@ export function formatValue(
case 'time':
return formatTimePart(serialToUtcDate(n), locale);
case 'datetime':
// a bare time of day (< 1 day) has no calendar date -- showing the
// epoch "30.12.1899" in front of it would be noise
if (n >= 0 && n < 1) return formatTimePart(serialToUtcDate(n), locale);
return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`;
case 'duration':
return formatDuration(n);
default: // 'auto'
return locale === 'plain'
? String(n)
+3 -2
View File
@@ -27,8 +27,9 @@ export type CellNumFmt =
| 'pct2' // percent, 2 decimals
| 'date' // e.g. 02.09.2026 -- value is a date serial, see engine/dates.ts
| 'dateiso' // e.g. 2026-09-02 -- ISO 8601, same regardless of locale
| 'time' // e.g. 14:30
| 'datetime'; // date + time
| 'time' // e.g. 14:30 -- time of day, wraps at 24 h
| 'datetime' // date + time
| 'duration'; // e.g. 37:30 -- elapsed hours, never wraps at 24 h
/** Explicit formatting for a single cell (all fields optional = inherit). */
export interface CellFormat {