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:
@@ -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