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:
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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; …)' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) ---
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user