diff --git a/docs/office-engines/CHANGES.md b/docs/office-engines/CHANGES.md index 50e78888e..20d502610 100644 --- a/docs/office-engines/CHANGES.md +++ b/docs/office-engines/CHANGES.md @@ -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 diff --git a/packages/office-sheets/src/formula/functions-date.ts b/packages/office-sheets/src/formula/functions-date.ts index 0a2e0b73c..737eab92c 100644 --- a/packages/office-sheets/src/formula/functions-date.ts +++ b/packages/office-sheets/src/formula/functions-date.ts @@ -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++; @@ -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); @@ -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); } @@ -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)); } } } @@ -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--; } } @@ -770,6 +774,15 @@ 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, ); @@ -777,20 +790,17 @@ function yearfracFunc( 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; @@ -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: { @@ -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(); + +/** + * `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. */ @@ -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; } diff --git a/packages/office-sheets/src/formula/functions-financial.ts b/packages/office-sheets/src/formula/functions-financial.ts index 4c01df5eb..49ae8b679 100644 --- a/packages/office-sheets/src/formula/functions-financial.ts +++ b/packages/office-sheets/src/formula/functions-financial.ts @@ -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, @@ -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 diff --git a/packages/office-sheets/test/formula/date-timezone.test.ts b/packages/office-sheets/test/formula/date-timezone.test.ts new file mode 100644 index 000000000..60734ccb8 --- /dev/null +++ b/packages/office-sheets/test/formula/date-timezone.test.ts @@ -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(); + 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'); + }); + }); +}