Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/office-engines/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
Baseline: Wafflebase 0.6.9, acde58012910ec68645c65b6896d5408fad1645c.
Modification dates: 2026-09-06 through 2026-09-13. Original-file hashes are in snapshot.json.

## Sheets date parsing — 2026-10-19

- Date-only strings (`YYYY-MM-DD`) are read as local calendar dates in the
formula date functions instead of UTC midnight, so DAY, WEEKDAY, EDATE,
DATEVALUE, NETWORKDAYS, WORKDAY and DATE() round trips no longer move back a
day west of UTC. Holiday sets, NETWORKDAYS and WEEKNUM count local calendar
days, also across a daylight saving change at midnight. YEARFRAC 30/360 reads
date-only values by calendar day and timestamps by their UTC day as before;
financial day counts round only between date-only values, so timestamps keep
their time of day. `test/formula/date-timezone.test.ts` runs them in zones
either side of UTC.

## Native Board surface — 2026-09-13

New Nautilo-authored UI in `packages/first-party-apps/board` composes the owned
Expand Down
131 changes: 94 additions & 37 deletions packages/office-sheets/src/formula/functions-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,13 +358,13 @@ function networkdaysFunc(
return endDate;
}

const direction = startDate <= endDate ? 1 : -1;
const direction = calendarDay(startDate) <= calendarDay(endDate) ? 1 : -1;
const start = direction === 1 ? startDate : endDate;
const end = direction === 1 ? endDate : startDate;
const endDay = calendarDay(direction === 1 ? endDate : startDate);

let count = 0;
const current = new Date(start);
while (current <= end) {
while (calendarDay(current) <= endDay) {
const day = current.getDay();
if (day !== 0 && day !== 6) {
count++;
Expand Down Expand Up @@ -406,20 +406,21 @@ function networkdaysintlFunc(
for (const sref of toSrefs([hNode.v])) {
const cell = grid.get(sref);
if (cell?.v) {
const hd = new Date(cell.v);
const hd = parseDateString(String(cell.v));
if (!isNaN(hd.getTime())) {
holidays.add(hd.toISOString().slice(0, 10));
holidays.add(formatDate(hd));
}
}
}
}
}

const direction = endDate >= startDate ? 1 : -1;
const endDay = calendarDay(endDate);
const direction = endDay >= calendarDay(startDate) ? 1 : -1;
const current = new Date(startDate);
let count = 0;
while ((direction > 0 && current <= endDate) || (direction < 0 && current >= endDate)) {
if (!weekendDays.has(current.getDay()) && !holidays.has(current.toISOString().slice(0, 10))) {
while ((direction > 0 && calendarDay(current) <= endDay) || (direction < 0 && calendarDay(current) >= endDay)) {
if (!weekendDays.has(current.getDay()) && !holidays.has(formatDate(current))) {
count++;
}
current.setDate(current.getDate() + direction);
Expand Down Expand Up @@ -589,13 +590,16 @@ function weeknumFunc(
type = Math.trunc(typeNode.v);
}

const jan1 = new Date(date.getFullYear(), 0, 1);
const startDay = type === 2 ? 1 : 0;
const jan1Day = jan1.getDay();
const jan1Day = new Date(date.getFullYear(), 0, 1).getDay();
const dayOffset = (jan1Day - startDay + 7) % 7;
const weekStart = new Date(jan1.getTime() - dayOffset * 86400000);
const diff = date.getTime() - weekStart.getTime();
const weekNum = Math.floor(diff / (7 * 86400000)) + 1;
// Whole calendar days since 1 January, counted in UTC so that a daylight
// saving change between the two dates cannot shorten the span by an hour.
const dayOfYear =
(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) -
Date.UTC(date.getFullYear(), 0, 1)) /
86400000;
const weekNum = Math.floor((dayOfYear + dayOffset) / 7) + 1;

return numNode(weekNum);
}
Expand Down Expand Up @@ -712,9 +716,9 @@ function workdayintlFunc(
for (const sref of toSrefs([hNode.v])) {
const cell = grid.get(sref);
if (cell?.v) {
const hd = new Date(cell.v);
const hd = parseDateString(String(cell.v));
if (!isNaN(hd.getTime())) {
holidays.add(hd.toISOString().slice(0, 10));
holidays.add(formatDate(hd));
}
}
}
Expand All @@ -726,7 +730,7 @@ function workdayintlFunc(
let remaining = Math.abs(numDays);
while (remaining > 0) {
result.setDate(result.getDate() + direction);
if (!weekendDays.has(result.getDay()) && !holidays.has(result.toISOString().slice(0, 10))) {
if (!weekendDays.has(result.getDay()) && !holidays.has(formatDate(result))) {
remaining--;
}
}
Expand Down Expand Up @@ -770,27 +774,33 @@ function yearfracFunc(

const s = startDate < endDate ? startDate : endDate;
const e = startDate < endDate ? endDate : startDate;
// 30/360 reads a date-only value as the calendar day written, and a
// timestamp by its UTC day as before, so one with an explicit offset gives
// the same result in every time zone.
const dayOf = (date: Date) =>
isDateOnlyValue(date)
? { y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate() }
: { y: date.getUTCFullYear(), m: date.getUTCMonth() + 1, d: date.getUTCDate() };
const sd = dayOf(s);
const ed = dayOf(e);
const actualDays = Math.round(
(e.getTime() - s.getTime()) / 86400000,
);

switch (basis) {
case 0: {
// US (NASD) 30/360
let d1 = s.getUTCDate();
let d2 = e.getUTCDate();
const m1 = s.getUTCMonth() + 1;
const m2 = e.getUTCMonth() + 1;
const y1 = s.getUTCFullYear();
const y2 = e.getUTCFullYear();
const isLastDayOfFeb = (d: Date): boolean => {
const y = d.getUTCFullYear();
const m = d.getUTCMonth();
return m === 1 && d.getUTCDate() === new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
};
if (isLastDayOfFeb(s)) {
let d1 = sd.d;
let d2 = ed.d;
const m1 = sd.m;
const m2 = ed.m;
const y1 = sd.y;
const y2 = ed.y;
const isLastDayOfFeb = (d: { y: number; m: number; d: number }): boolean =>
d.m === 2 && d.d === new Date(Date.UTC(d.y, 2, 0)).getUTCDate();
if (isLastDayOfFeb(sd)) {
d1 = 30;
if (isLastDayOfFeb(e)) d2 = 30;
if (isLastDayOfFeb(ed)) d2 = 30;
}
if (d1 === 31) d1 = 30;
if (d2 === 31 && d1 === 30) d2 = 30;
Expand All @@ -799,12 +809,9 @@ function yearfracFunc(
}
case 4: {
// European 30/360
const d1 = Math.min(s.getUTCDate(), 30);
const d2 = Math.min(e.getUTCDate(), 30);
const days30 =
(e.getUTCFullYear() - s.getUTCFullYear()) * 360 +
(e.getUTCMonth() - s.getUTCMonth()) * 30 +
(d2 - d1);
const d1 = Math.min(sd.d, 30);
const d2 = Math.min(ed.d, 30);
const days30 = (ed.y - sd.y) * 360 + (ed.m - sd.m) * 30 + (d2 - d1);
return numNode(days30 / 360);
}
case 1: {
Expand Down Expand Up @@ -897,13 +904,63 @@ export function parseDate(
const str = toStr(node, grid);
if (str.t === 'err') return str;

const date = new Date(str.v);
const date = parseDateString(str.v);
if (isNaN(date.getTime())) {
return ErrNode.VALUE;
}
return date;
}

/** Dates `parseDateString` read from a date-only (`YYYY-MM-DD`) string. */
const dateOnlyValues = new WeakSet<Date>();

/**
* `isDateOnlyValue` reports whether `date` was parsed from a date-only string,
* as opposed to a timestamp.
*/
export function isDateOnlyValue(date: Date): boolean {
return dateOnlyValues.has(date);
}

/**
* `calendarDay` returns the local calendar date of `date` as a whole number of
* days since 1970-01-01, so that two dates compare by day whatever their time
* of day or a daylight saving change between them.
*/
function calendarDay(date: Date): number {
const utc = new Date(0);
utc.setUTCFullYear(date.getFullYear(), date.getMonth(), date.getDate());
return utc.getTime() / 86400000;
}

/**
* `parseDateString` parses a date string into a Date at local midnight for
* date-only values.
*
* `new Date('2024-03-15')` is defined to parse a date-only ISO string as UTC
* midnight, while every date function here reads the result with local getters
* (`getDate`, `getDay`, ...) and formats with `formatDate`. West of UTC that
* moved every such date back by one day. Other strings keep the built-in parser.
*/
function parseDateString(text: string): Date {
const isoDate = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(text);
if (isoDate) {
const year = Number(isoDate[1]);
const month = Number(isoDate[2]);
const day = Number(isoDate[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) {
return new Date(NaN);
}
// Year, month and day are set together: the Date constructor maps years
// 0-99 to 19xx, which would roll a leap day such as 0000-02-29 over first.
const date = new Date(2000, 0, 1);
date.setFullYear(year, month - 1, day);
dateOnlyValues.add(date);
return date;
}
return new Date(text);
}

/**
* `parseDateTime` parses either a full datetime/date value or a time literal.
*/
Expand Down Expand Up @@ -935,7 +992,7 @@ function parseDateTime(
return new Date(1970, 0, 1, hour, minute, second);
}

const date = new Date(str.v);
const date = parseDateString(str.v);
if (isNaN(date.getTime())) {
return ErrNode.VALUE;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/office-sheets/src/formula/functions-financial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
toStr,
getRefsFromExpression,
} from './functions-helpers';
import { parseDate, formatDate } from './functions-date';
import { parseDate, formatDate, isDateOnlyValue } from './functions-date';

function getCashFlowValues(
expr: ParseTree,
Expand Down Expand Up @@ -1675,7 +1675,12 @@ function yearFrac(startDate: Date, endDate: Date, basis: number): number {
const ed = new Date(endDate);
const y1 = sd.getFullYear(), m1 = sd.getMonth() + 1, d1Orig = sd.getDate();
const y2 = ed.getFullYear(), m2 = ed.getMonth() + 1, d2Orig = ed.getDate();
const actualDays = (ed.getTime() - sd.getTime()) / 86400000;
// Two date-only values are whole days apart, but two local midnights either
// side of a DST change are not a whole number of 24-hour days, so their count
// is rounded. A timestamp keeps its time of day.
const elapsedDays = (ed.getTime() - sd.getTime()) / 86400000;
const actualDays =
isDateOnlyValue(startDate) && isDateOnlyValue(endDate) ? Math.round(elapsedDays) : elapsedDays;

switch (basis) {
case 0: { // US 30/360
Expand Down
106 changes: 106 additions & 0 deletions packages/office-sheets/test/formula/date-timezone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { evaluate } from '../../src/formula/formula';
import { Cell } from '../../src/model/core/types';

// Date-only strings such as "2024-03-15" are calendar dates. These checks run
// the date functions in time zones on both sides of UTC, so the result does not
// depend on the zone of the machine running the suite.
const zones = [
'UTC',
'America/New_York',
'America/Los_Angeles',
'America/Santiago',
'Asia/Kolkata',
'Pacific/Auckland',
];

for (const zone of zones) {
describe(`date functions in ${zone}`, () => {
// Node re-reads TZ when it changes, so Date's local getters follow it.
beforeAll(() => {
vi.stubEnv('TZ', zone);
});

afterAll(() => {
vi.unstubAllEnvs();
});

it('reads a date-only string as that calendar day', () => {
expect(evaluate('=DAY("2024-03-15")')).toBe('15');
expect(evaluate('=MONTH("2024-03-01")')).toBe('3');
expect(evaluate('=YEAR("2024-01-01")')).toBe('2024');
expect(evaluate('=WEEKDAY("2024-03-17")')).toBe('1');
expect(evaluate('=DATEVALUE("2024-03-15")')).toBe('2024-03-15');
expect(evaluate('=DAY(DATE(2024,3,15))')).toBe('15');
});

it('keeps date arithmetic on calendar days', () => {
expect(evaluate('=EDATE("2024-03-15",-1)')).toBe('2024-02-15');
expect(evaluate('=EOMONTH("2024-01-15",1)')).toBe('2024-02-29');
expect(evaluate('=NETWORKDAYS("2024-03-11","2024-03-15")')).toBe('5');
expect(evaluate('=WORKDAY("2024-01-05",1)')).toBe('2024-01-08');
expect(evaluate('=YEARFRAC("2024-01-01","2024-07-01",0)')).toBe('0.5');
});

it('counts weeks across a daylight saving change', () => {
// 17 March 2024 is a Sunday, one week after the US clocks went forward.
expect(evaluate('=WEEKNUM("2024-03-17")')).toBe('12');
expect(evaluate('=WEEKNUM("2024-03-16")')).toBe('11');
});

it('counts business days across a midnight daylight saving change', () => {
// Santiago moved its clocks forward at midnight on 8 September 2019, so
// that day has no 00:00.
expect(evaluate('=NETWORKDAYS("2019-09-06","2019-09-09")')).toBe('2');
expect(evaluate('=NETWORKDAYS("2019-09-09","2019-09-06")')).toBe('-2');
expect(evaluate('=NETWORKDAYS.INTL("2019-09-06","2019-09-09")')).toBe('2');
expect(evaluate('=NETWORKDAYS.INTL("2019-09-09","2019-09-06")')).toBe('-2');
});

it('counts whole days between date-only values in financial day counts', () => {
// 100 a year of interest, basis 3 (actual/365).
expect(Number(evaluate('=ACCRINTM("2024-03-09","2024-03-11",0.1,1000,3)'))).toBeCloseTo(200 / 365, 10);
expect(Number(evaluate('=ACCRINTM("2019-09-06","2019-09-09",0.1,1000,3)'))).toBeCloseTo(300 / 365, 10);
});

it('keeps the time of day of timestamps in financial day counts', () => {
expect(Number(evaluate('=ACCRINTM("2024-01-01T00:00:00","2024-01-01T11:59:59",0.1,1000,3)'))).toBeCloseTo(
(100 * 43199) / 86400 / 365,
10,
);
expect(Number(evaluate('=ACCRINTM("2024-01-01T00:00:00","2024-01-01T12:00:00",0.1,1000,3)'))).toBeCloseTo(
50 / 365,
10,
);
});

it('reads YEARFRAC 30/360 dates from date-only values and timestamps', () => {
// A date-only value is the calendar day written.
expect(Number(evaluate('=YEARFRAC("2024-02-29","2024-03-31",0)'))).toBeCloseTo(30 / 360, 10);
expect(Number(evaluate('=YEARFRAC("2024-02-29","2024-03-31",4)'))).toBeCloseTo(31 / 360, 10);
// A timestamp with an explicit offset is the same instant in every zone
// and keeps its UTC day: 1 March 07:30 to 1 April 07:30.
expect(Number(evaluate('=YEARFRAC("2024-02-29T23:30:00-08:00","2024-03-31T23:30:00-08:00",0)'))).toBeCloseTo(
30 / 360,
10,
);
expect(Number(evaluate('=YEARFRAC("2024-02-29T23:30:00-08:00","2024-03-31T23:30:00-08:00",4)'))).toBeCloseTo(
30 / 360,
10,
);
});

it('keeps a leap day in years the Date constructor maps to 19xx', () => {
expect(evaluate('=DAY("0000-02-29")')).toBe('29');
expect(evaluate('=MONTH("0000-02-29")')).toBe('2');
expect(evaluate('=DAY("0099-02-29")')).toBe('1');
});

it('matches holidays on the same calendar day', () => {
const grid = new Map<string, Cell>();
grid.set('A1', { v: '2024-03-13' } as Cell);
expect(evaluate('=NETWORKDAYS.INTL("2024-03-11","2024-03-15",1,A1)', grid)).toBe('4');
expect(evaluate('=WORKDAY.INTL("2024-03-12",1,1,A1)', grid)).toBe('2024-03-14');
});
});
}
Loading