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
+40 -7
View File
@@ -12,7 +12,7 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**.
```bash ```bash
npm install npm install
npm run dev # Vite-Devserver 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 build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config) 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; `MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann;
Sonst?)`, `AND(Bedingung1; Bedingung2; …)`, `OR(Bedingung1; Bedingung2; …)`, Sonst?)`, `AND(Bedingung1; Bedingung2; …)`, `OR(Bedingung1; Bedingung2; …)`,
`NOT(Bedingung)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `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 [Vergleichsmodus]; [Suchmodus])` — plus alle über den **„fx"-Button** angelegten
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
die sich für den Aufrufer identisch verhalten. Argumente gemischt: 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. 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) 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 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 (`02/09/2026`) — Tag-zuerst vs. Monat-zuerst ist über die unterstützten
Locales (en-US ist monatzuerst, alle anderen tagzuerst) nicht eindeutig Locales (en-US ist monatzuerst, alle anderen tagzuerst) nicht eindeutig
auflösbar; falsches Raten würde das Datum lautlos verfälschen, deshalb 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)` = Datums-/Zeitwert (Überlauf wie in Excel erlaubt: `DATE(2026;13;1)` =
01.01.2027, `TIME(25;0;0)` = 01:00 des Folgetags-Anteils); `TODAY()` / 01.01.2027, `TIME(25;0;0)` = 01:00 des Folgetags-Anteils); `TODAY()` /
`NOW()` liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse `NOW()` liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse
bekommen **kein** automatisches Zellformat (wie bei jeder anderen Formel bekommen **kein** automatisches Zellformat — das Zahlenformat-Dropdown auf
auch) — das Zahlenformat-Dropdown auf „Datum"/„Zeit"/„Datum + Zeit" stellen. „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 `TODAY`/`NOW` sind **nicht volatil**: diese Engine rechnet rein
vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()` vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()`
aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von
selbst beim Tageswechsel (siehe „Bekannte Grenzen" unten). 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` Das Zahlenformat **„Datum (ISO)"** zeigt ein Datum immer als `JJJJ-MM-TT`
(`2026-09-02`) — unabhängig von der aktiven Locale (Blatt/Dokument oder (`2026-09-02`) — unabhängig von der aktiven Locale (Blatt/Dokument oder
Zelle), anders als „Datum", das der jeweiligen Locale folgt (`02.09.2026` 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 ## Tests
`npm run test` — Vitest, 528 Tests: `npm run test` — Vitest, 553 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, 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. und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs.
Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar, Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar,
s. o.). Formel-Ergebnisse (`=DATE(...)`, `=TODAY()`, `=A1+30`) bekommen kein 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 `NOW()` sind nicht volatil: sie aktualisieren sich erst bei der nächsten
Neuberechnung ihrer eigenen Zelle, nicht spontan mit der Zeit. Neuberechnung ihrer eigenen Zelle, nicht spontan mit der Zeit.
- **XLOOKUP** bewusst ohne: Näherungsvergleich (`Vergleichsmodus` -1/1) auf - **XLOOKUP** bewusst ohne: Näherungsvergleich (`Vergleichsmodus` -1/1) auf
+34
View File
@@ -435,6 +435,23 @@ const FUNCTION_DESCRIPTIONS: Record<string, string> = {
TODAY: 'Heutiges Datum.', TODAY: 'Heutiges Datum.',
NOW: 'Aktuelles Datum mit Uhrzeit.', NOW: 'Aktuelles Datum mit Uhrzeit.',
XLOOKUP: 'Sucht einen Wert und gibt den passenden Eintrag aus einem zweiten Bereich zurück.', 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. */ /** 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', TODAY: 'heute datum',
NOW: 'jetzt zeit', NOW: 'jetzt zeit',
XLOOKUP: 'suchen sverweis verweis nachschlagen', 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) => ({ 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 expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
}); });
it('rejects an out-of-range time', () => { it('rejects an out-of-range minute/second', () => {
expect(parseDateTimeLiteral('25:00')).toBeNull();
expect(parseDateTimeLiteral('12:60')).toBeNull(); 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', () => { 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)); 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 /** 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). */ * timezone-relative instant), as an integer serial (no time-of-day part). */
export function todaySerial(): number { 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_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 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_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 DOT_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
const TIME_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/; 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, /** 2-digit year -> 4-digit, the usual spreadsheet convention: 00-29 -> 20xx,
* 30-99 -> 19xx. */ * 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 * Deliberately locale-independent and conservative: only ISO dates
* (`2026-09-02`) and dot-separated day.month.year dates (`02.09.2026`, * (`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 * with a space or `T`. Slash-separated dates (`02/09/2026`) are
* intentionally NOT recognized: day-first vs. month-first is ambiguous * intentionally NOT recognized: day-first vs. month-first is ambiguous
* across this app's supported locales (en-US is month-first, every other * 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 h = +m[1];
const mi = +m[2]; const mi = +m[2];
const s = m[3] ? +m[3] : 0; const s = m[3] ? +m[3] : 0;
if (h > 23 || mi > 59 || s > 59) return null; if (mi > 59 || s > 59) return null;
return { kind: 'time', serial: timeSerial(h, mi, s) }; 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; return null;
} }
+252 -1
View File
@@ -14,7 +14,19 @@
*/ */
import { FormulaError, isError, type CellValue } from './types'; 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; 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 !== ''; 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> = { export const FUNCTIONS: Record<string, FunctionFn> = {
SUM(values) { SUM(values) {
return nums(values).reduce((a, b) => a + b, 0); return nums(values).reduce((a, b) => a + b, 0);
@@ -157,6 +226,171 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
return nowSerial(); 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]; // XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
// [Vergleichsmodus]; [Suchmodus]) -- like IF, evaluator.ts special-cases // [Vergleichsmodus]; [Suchmodus]) -- like IF, evaluator.ts special-cases
// this name *before* ever reaching FUNCTIONS. Two reasons, not just one: // 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: 'TIME', hint: 'TIME(Std; Min; Sek)' },
{ name: 'TODAY', hint: 'TODAY()' }, { name: 'TODAY', hint: 'TODAY()' },
{ name: 'NOW', hint: 'NOW()' }, { 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; …)' }, { 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); 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', () => { it('inserting a new table and referencing it across sheets works', () => {
const s = fresh(); const s = fresh();
const sheet2 = s.doc.sheets[1].id; 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'); 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', () => { it('empty cell / error still short-circuit before any date formatting', () => {
expect(formatValue(null, 'de-DE', 'date')).toBe(''); expect(formatValue(null, 'de-DE', 'date')).toBe('');
}); });
+26 -5
View File
@@ -62,6 +62,7 @@ import type {
ButtonObject, ButtonObject,
CellCoord, CellCoord,
CellFormat, CellFormat,
CellNumFmt,
CellGraphNode, CellGraphNode,
ChartObject, ChartObject,
CustomFunction, CustomFunction,
@@ -1767,11 +1768,31 @@ function createStore() {
return wb.cellValue(tableId, col, row); return wb.cellValue(tableId, col, row);
} }
function cellDisplay(tableId: string, col: number, row: number): string { function cellDisplay(tableId: string, col: number, row: number): string {
return formatValue( return formatValue(cellValue(tableId, col, row), effectiveLocale(tableId), displayNumFmt(tableId, col, row));
cellValue(tableId, col, row), }
effectiveLocale(tableId), /** The cell's number format, or -- for a formula cell left on "auto" -- a
tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto', * 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) --- // ---- 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: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
{ id: 'time', label: 'Zeit · 14:30' }, { id: 'time', label: 'Zeit · 14:30' },
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 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)); const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
export function isCellNumFmt(x: unknown): x is CellNumFmt { export function isCellNumFmt(x: unknown): x is CellNumFmt {
@@ -92,6 +93,18 @@ function formatTimePart(d: Date, locale: NumberLocale): string {
}).format(d); }).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). */ /** Render a computed value for display (grid cell / status bar / chart). */
export function formatValue( export function formatValue(
v: CellValue, v: CellValue,
@@ -128,7 +141,12 @@ export function formatValue(
case 'time': case 'time':
return formatTimePart(serialToUtcDate(n), locale); return formatTimePart(serialToUtcDate(n), locale);
case 'datetime': 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)}`; return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`;
case 'duration':
return formatDuration(n);
default: // 'auto' default: // 'auto'
return locale === 'plain' return locale === 'plain'
? String(n) ? String(n)
+3 -2
View File
@@ -27,8 +27,9 @@ export type CellNumFmt =
| 'pct2' // percent, 2 decimals | 'pct2' // percent, 2 decimals
| 'date' // e.g. 02.09.2026 -- value is a date serial, see engine/dates.ts | '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 | 'dateiso' // e.g. 2026-09-02 -- ISO 8601, same regardless of locale
| 'time' // e.g. 14:30 | 'time' // e.g. 14:30 -- time of day, wraps at 24 h
| 'datetime'; // date + time | '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). */ /** Explicit formatting for a single cell (all fields optional = inherit). */
export interface CellFormat { export interface CellFormat {