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
+29
View File
@@ -133,6 +133,35 @@ describe('seed document acceptance (canvas model)', () => {
expect(s.canUndo.value).toBe(false);
});
it('a formula over times shows as a duration, past 24 hours', () => {
const s = fresh();
s.newDocument();
const t = tableId(s, 'Tabelle 1');
setCell(s, 'Tabelle 1', 'A1', '08:30');
setCell(s, 'Tabelle 1', 'A2', '09:15');
setCell(s, 'Tabelle 1', 'A3', '20:00');
setCell(s, 'Tabelle 1', 'A4', '=SUM(A1:A3)');
setCell(s, 'Tabelle 1', 'B1', '=A2-A1');
setCell(s, 'Tabelle 1', 'B2', '=A4+B1'); // chain: a sum of formulas
expect(s.cellDisplay(t, 0, 3)).toBe('37:45');
expect(s.cellDisplay(t, 1, 0)).toBe('00:45');
expect(s.cellDisplay(t, 1, 1)).toBe('38:30');
// an explicit format always wins over the inferred one
s.setRangeFormat(t, { col: 0, row: 3 }, { col: 0, row: 3 }, { numFmt: 'time' });
expect(s.cellDisplay(t, 0, 3)).toBe('13:45');
});
it('a typed 24+ hour time becomes a duration; dates are not durations', () => {
const s = fresh();
s.newDocument();
const t = tableId(s, 'Tabelle 1');
setCell(s, 'Tabelle 1', 'A1', '37:30');
expect(s.cellDisplay(t, 0, 0)).toBe('37:30');
setCell(s, 'Tabelle 1', 'C1', '2026-09-02');
setCell(s, 'Tabelle 1', 'C2', '=C1+30');
expect(s.cellDisplay(t, 2, 1)).not.toContain(':');
});
it('inserting a new table and referencing it across sheets works', () => {
const s = fresh();
const sheet2 = s.doc.sheets[1].id;
+12
View File
@@ -23,6 +23,18 @@ describe('formatValue: date/time', () => {
expect(formatValue(dayAtNoon, 'plain', 'datetime')).toBe('2026-09-02 12:00');
});
it('date + time shows only the time for a bare time of day (no 1899 date)', () => {
expect(formatValue(timeSerial(12, 23, 0), 'de-DE', 'datetime')).toBe('12:23');
});
it('"duration" keeps counting hours past 24', () => {
expect(formatValue(37.5 / 24, 'de-DE', 'duration')).toBe('37:30');
expect(formatValue(timeSerial(8, 5, 0), 'de-DE', 'duration')).toBe('08:05');
expect(formatValue((100 * 3600 + 30) / 86400, 'de-DE', 'duration')).toBe('100:00:30');
expect(formatValue(-1.5 / 24, 'de-DE', 'duration')).toBe('-01:30');
expect(formatValue(0, 'de-DE', 'duration')).toBe('00:00');
});
it('empty cell / error still short-circuit before any date formatting', () => {
expect(formatValue(null, 'de-DE', 'date')).toBe('');
});
+26 -5
View File
@@ -62,6 +62,7 @@ import type {
ButtonObject,
CellCoord,
CellFormat,
CellNumFmt,
CellGraphNode,
ChartObject,
CustomFunction,
@@ -1767,11 +1768,31 @@ function createStore() {
return wb.cellValue(tableId, col, row);
}
function cellDisplay(tableId: string, col: number, row: number): string {
return formatValue(
cellValue(tableId, col, row),
effectiveLocale(tableId),
tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto',
);
return formatValue(cellValue(tableId, col, row), effectiveLocale(tableId), displayNumFmt(tableId, col, row));
}
/** The cell's number format, or -- for a formula cell left on "auto" -- a
* format inferred from what it computes with: a formula whose numeric
* inputs are all times/durations (`=SUM(B2:B8)`, `=C2-B2` over clock
* times) shows as a duration, so a total of 37.5 hours reads `37:30`
* instead of wrapping to `13:30` or showing the raw day fraction. Walks
* formula chains (a sum of sums), bounded by `budget` cells visited. */
function displayNumFmt(
tableId: string,
col: number,
row: number,
budget = { left: 2000 },
): CellNumFmt {
const explicit = tableById(tableId)?.formats?.[`${col},${row}`]?.numFmt ?? 'auto';
if (explicit !== 'auto' || !cellInput(tableId, col, row).startsWith('=')) return explicit;
let sawTime = false;
for (const p of wb.graphPrecedents(tableId, col, row)) {
if (--budget.left < 0) return 'auto';
if (typeof wb.cellValue(p.tableId, p.col, p.row) !== 'number') continue;
const f = displayNumFmt(p.tableId, p.col, p.row, budget);
if (f !== 'time' && f !== 'duration') return 'auto';
sawTime = true;
}
return sawTime ? 'duration' : 'auto';
}
// ---- number-format locale (object -> sheet -> document cascade) ---
+18
View File
@@ -46,6 +46,7 @@ export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
{ id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
{ id: 'time', label: 'Zeit · 14:30' },
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
{ id: 'duration', label: 'Dauer · 37:30' },
];
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
export function isCellNumFmt(x: unknown): x is CellNumFmt {
@@ -92,6 +93,18 @@ function formatTimePart(d: Date, locale: NumberLocale): string {
}).format(d);
}
/** Elapsed time as `[h]:mm` -- hours keep counting past 24 (`37:30`), the
* way a sum of working times should read; seconds only when there are any.
* Locale-independent: `:` is the time separator in every supported locale. */
function formatDuration(n: number): string {
let sec = Math.round(Math.abs(n) * 86400);
const h = Math.floor(sec / 3600);
sec -= h * 3600;
const m = Math.floor(sec / 60);
const s = sec - m * 60;
return `${n < 0 && (h || m || s) ? '-' : ''}${pad2(h)}:${pad2(m)}${s ? `:${pad2(s)}` : ''}`;
}
/** Render a computed value for display (grid cell / status bar / chart). */
export function formatValue(
v: CellValue,
@@ -128,7 +141,12 @@ export function formatValue(
case 'time':
return formatTimePart(serialToUtcDate(n), locale);
case 'datetime':
// a bare time of day (< 1 day) has no calendar date -- showing the
// epoch "30.12.1899" in front of it would be noise
if (n >= 0 && n < 1) return formatTimePart(serialToUtcDate(n), locale);
return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`;
case 'duration':
return formatDuration(n);
default: // 'auto'
return locale === 'plain'
? String(n)
+3 -2
View File
@@ -27,8 +27,9 @@ export type CellNumFmt =
| 'pct2' // percent, 2 decimals
| 'date' // e.g. 02.09.2026 -- value is a date serial, see engine/dates.ts
| 'dateiso' // e.g. 2026-09-02 -- ISO 8601, same regardless of locale
| 'time' // e.g. 14:30
| 'datetime'; // date + time
| 'time' // e.g. 14:30 -- time of day, wraps at 24 h
| 'datetime' // date + time
| 'duration'; // e.g. 37:30 -- elapsed hours, never wraps at 24 h
/** Explicit formatting for a single cell (all fields optional = inherit). */
export interface CellFormat {