diff --git a/README.md b/README.md index f5bad26..75f07bf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**. ```bash npm install npm run dev # Vite-Devserver -npm run test # Vitest-Suite (Engine + Store + IO), 528 Tests +npm run test # Vitest-Suite (Engine + Store + IO), 553 Tests npm run build # Typecheck + Produktionsbuild npm run lint # ESLint (flat config) ``` @@ -878,7 +878,10 @@ das Dropdown, ohne die Bearbeitung selbst abzubrechen. `MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann; Sonst?)`, `AND(Bedingung1; Bedingung2; …)`, `OR(Bedingung1; Bedingung2; …)`, `NOT(Bedingung)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, -`NOW()`, `XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; +`NOW()`, die Datums-/Zeitfunktionen `YEAR`, `MONTH`, `DAY`, `HOUR`, +`MINUTE`, `SECOND`, `WEEKDAY`, `ISOWEEKNUM`, `WEEKNUM`, `EDATE`, `EOMONTH`, +`DAYS`, `DATEDIF`, `NETWORKDAYS`, `WORKDAY`, `DATEVALUE`, `TIMEVALUE` (s. „Datum +& Zeit" unten), `XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus])` — plus alle über den **„fx"-Button** angelegten benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch verhalten. Argumente gemischt: @@ -922,7 +925,8 @@ ohne Zusatzaufwand: `=fällig - rechnungsdatum` zählt Tage, `=rechnungsdatum und sofort mit dem passenden Format angezeigt — genau wie in Excel/Numbers. Erkannt werden ISO (`2026-09-02`, auch mit `14:30`/`14:30:15` angehängt) und Punkt-Daten (`02.09.2026`, `02.09.26`, ebenfalls mit Uhrzeit), sowie - reine Uhrzeiten (`14:30`). **Absichtlich nicht** erkannt: Schrägstrich-Daten + reine Uhrzeiten (`14:30`); ab 24 Stunden (`37:30`) wird daraus eine + **Dauer**. **Absichtlich nicht** erkannt: Schrägstrich-Daten (`02/09/2026`) — Tag-zuerst vs. Monat-zuerst ist über die unterstützten Locales (en-US ist monatzuerst, alle anderen tagzuerst) nicht eindeutig auflösbar; falsches Raten würde das Datum lautlos verfälschen, deshalb @@ -932,13 +936,39 @@ ohne Zusatzaufwand: `=fällig - rechnungsdatum` zählt Tage, `=rechnungsdatum Datums-/Zeitwert (Überlauf wie in Excel erlaubt: `DATE(2026;13;1)` = 01.01.2027, `TIME(25;0;0)` = 01:00 des Folgetags-Anteils); `TODAY()` / `NOW()` liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse - bekommen **kein** automatisches Zellformat (wie bei jeder anderen Formel - auch) — das Zahlenformat-Dropdown auf „Datum"/„Zeit"/„Datum + Zeit" stellen. + bekommen **kein** automatisches Zellformat — das Zahlenformat-Dropdown auf + „Datum"/„Zeit"/„Datum + Zeit" stellen. Einzige Ausnahme: eine Formel, deren + Zahlen-Eingaben **alle Zeiten oder Dauern** sind (`=SUM(B2:B8)` über + Arbeitszeiten, `=C2-B2` für Ende − Beginn), zeigt ihr Ergebnis als + **Dauer**, solange die Zelle auf „Automatisch" steht. `TODAY`/`NOW` sind **nicht volatil**: diese Engine rechnet rein vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()` aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von selbst beim Tageswechsel (siehe „Bekannte Grenzen" unten). +Das Zahlenformat **„Dauer"** zählt Stunden über 24 hinaus weiter (`37:30` +statt `13:30`) — für Summen von Arbeits- oder Laufzeiten; Sekunden +erscheinen nur, wenn welche da sind (`100:00:30`). **„Zeit"** bleibt eine +Uhrzeit und springt nach 24 Stunden wieder auf `00:00`. **„Datum + Zeit"** +zeigt bei einer reinen Uhrzeit (unter einem Tag) nur die Uhrzeit statt des +Nullpunkts 30.12.1899. + +**Datums- und Zeitfunktionen**: `YEAR`/`MONTH`/`DAY`/`HOUR`/`MINUTE`/`SECOND` +lesen einen Teil aus (auf ganze Sekunden gerundet). Wo Excel eine +US-Voreinstellung hat, gilt hier **ISO 8601**: `WEEKDAY(Datum; [Typ])` +zählt Montag = 1 … Sonntag = 7 (Typ 1: Sonntag = 1, Typ 3: Montag = 0), +`ISOWEEKNUM` und `WEEKNUM(Datum; [Typ])` liefern die ISO-Kalenderwoche +(`WEEKNUM` Typ 1/2: US-Zählung mit Wochenbeginn Sonntag/Montag). +`EDATE(Datum; Monate)` verschiebt um Monate (31.01. + 1 = 28./29.02.), +`EOMONTH(Datum; Monate)` liefert den Monatsletzten, `DAYS(Ende; Start)` die +Kalendertage dazwischen, `DATEDIF(Start; Ende; Einheit)` den Abstand in +`"Y"`/`"M"`/`"D"` bzw. als Rest `"YM"`/`"MD"`/`"YD"`. +`NETWORKDAYS(Start; Ende; [Feiertage])` zählt Arbeitstage (Mo–Fr, beide +Enden eingeschlossen), `WORKDAY(Start; Tage; [Feiertage])` rechnet eine +Frist in Arbeitstagen — Feiertage als Zellen/Bereich. `DATEVALUE(Text)` und +`TIMEVALUE(Text)` lesen Datum bzw. Uhrzeit aus einem Text in jedem Format, +das auch beim Eintippen erkannt wird. Ungültige Argumente ergeben `#VALUE!`. + Das Zahlenformat **„Datum (ISO)"** zeigt ein Datum immer als `JJJJ-MM-TT` (`2026-09-02`) — unabhängig von der aktiven Locale (Blatt/Dokument oder Zelle), anders als „Datum", das der jeweiligen Locale folgt (`02.09.2026` @@ -1082,7 +1112,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 528 Tests: +`npm run test` — Vitest, 553 Tests: - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, @@ -1404,7 +1434,10 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs. Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar, s. o.). Formel-Ergebnisse (`=DATE(...)`, `=TODAY()`, `=A1+30`) bekommen kein - automatisches Zellformat — wie jedes andere Formelergebnis auch. `TODAY()`/ + automatisches Zellformat — außer „Dauer" für Formeln über reine Zeiten. + Das gilt auch dann, wenn die Formel die Zeiten in Stunden umrechnet + (`=A1*24`); dort das Format „Automatisch" durch „Ohne Formatierung" o. ä. + ersetzen. `TODAY()`/ `NOW()` sind nicht volatil: sie aktualisieren sich erst bei der nächsten Neuberechnung ihrer eigenen Zelle, nicht spontan mit der Zeit. - **XLOOKUP** bewusst ohne: Näherungsvergleich (`Vergleichsmodus` -1/1) auf diff --git a/src/data/codeReference.ts b/src/data/codeReference.ts index 582ff51..292ef8b 100644 --- a/src/data/codeReference.ts +++ b/src/data/codeReference.ts @@ -435,6 +435,23 @@ const FUNCTION_DESCRIPTIONS: Record = { 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 = { 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) => ({ diff --git a/src/engine/__tests__/dateFunctions.spec.ts b/src/engine/__tests__/dateFunctions.spec.ts new file mode 100644 index 0000000..2ae6cce --- /dev/null +++ b/src/engine/__tests__/dateFunctions.spec.ts @@ -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); + }); +}); diff --git a/src/engine/__tests__/dates.spec.ts b/src/engine/__tests__/dates.spec.ts index efecd6f..82dedf6 100644 --- a/src/engine/__tests__/dates.spec.ts +++ b/src/engine/__tests__/dates.spec.ts @@ -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', () => { diff --git a/src/engine/dates.ts b/src/engine/dates.ts index 7b677d1..0f11b18 100644 --- a/src/engine/dates.ts +++ b/src/engine/dates.ts @@ -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; } diff --git a/src/engine/functions.ts b/src/engine/functions.ts index b660d82..e4f0b7c 100644 --- a/src/engine/functions.ts +++ b/src/engine/functions.ts @@ -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 = []; + 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 { + const out = new Set(); + for (const v of values) if (typeof v === 'number') out.add(serialParts(v).day); + return out; +} + +function isWorkday(day: number, holidays: Set): 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 = { SUM(values) { return nums(values).reduce((a, b) => a + b, 0); @@ -157,6 +226,171 @@ export const FUNCTIONS: Record = { 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; …)' }, ]; diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index fbc1e81..3ddfe3d 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -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; diff --git a/src/store/__tests__/format.spec.ts b/src/store/__tests__/format.spec.ts index 4748b95..ebae8ab 100644 --- a/src/store/__tests__/format.spec.ts +++ b/src/store/__tests__/format.spec.ts @@ -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(''); }); diff --git a/src/store/document.ts b/src/store/document.ts index 6371b61..37aa7bd 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -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) --- diff --git a/src/store/format.ts b/src/store/format.ts index 67cb3a5..7b82df4 100644 --- a/src/store/format.ts +++ b/src/store/format.ts @@ -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) diff --git a/src/store/types.ts b/src/store/types.ts index c090d3e..3fc7fef 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -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 {