From b6d2e201b627794833932d722b50f4bdd68c800e Mon Sep 17 00:00:00 2001 From: HEY Research Lab <327801004+hey-research-lab@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:39:50 +0800 Subject: [PATCH 1/2] fix(sheets): read date-only strings as local calendar dates parseDate used new Date(text), which parses a date-only ISO string such as "2024-03-15" as UTC midnight, while the date functions read the result with local getters and format it with formatDate. West of UTC every such date moved back a day: DAY("2024-03-15") returned 14, WEEKDAY, EDATE, DATEVALUE, NETWORKDAYS, WORKDAY and DATE() round trips were off by one, and eight existing formula tests fail under TZ=America/New_York. Date-only strings are now built with the local Date constructor. The holiday sets of NETWORKDAYS.INTL and WORKDAY.INTL are keyed by the local calendar date, YEARFRAC reads local fields, WEEKNUM counts whole days so a DST change cannot shift the week, and the financial yearFrac rounds its day count. A new test runs these in zones on both sides of UTC. --- docs/office-engines/CHANGES.md | 8 ++ .../src/formula/functions-date.ts | 81 +++++++++++++------ .../src/formula/functions-financial.ts | 4 +- .../test/formula/date-timezone.test.ts | 51 ++++++++++++ 4 files changed, 117 insertions(+), 27 deletions(-) create mode 100644 packages/office-sheets/test/formula/date-timezone.test.ts diff --git a/docs/office-engines/CHANGES.md b/docs/office-engines/CHANGES.md index 50e78888e..bed895018 100644 --- a/docs/office-engines/CHANGES.md +++ b/docs/office-engines/CHANGES.md @@ -3,6 +3,14 @@ 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, YEARFRAC and WEEKNUM count local calendar days; + `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..8e3e4c35e 100644 --- a/packages/office-sheets/src/formula/functions-date.ts +++ b/packages/office-sheets/src/formula/functions-date.ts @@ -406,9 +406,9 @@ 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)); } } } @@ -419,7 +419,7 @@ function networkdaysintlFunc( 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))) { + if (!weekendDays.has(current.getDay()) && !holidays.has(formatDate(current))) { count++; } current.setDate(current.getDate() + direction); @@ -589,13 +589,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 +715,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 +729,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--; } } @@ -777,16 +780,16 @@ 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(); + let d1 = s.getDate(); + let d2 = e.getDate(); + const m1 = s.getMonth() + 1; + const m2 = e.getMonth() + 1; + const y1 = s.getFullYear(); + const y2 = e.getFullYear(); 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(); + const y = d.getFullYear(); + const m = d.getMonth(); + return m === 1 && d.getDate() === new Date(Date.UTC(y, m + 1, 0)).getUTCDate(); }; if (isLastDayOfFeb(s)) { d1 = 30; @@ -799,11 +802,11 @@ function yearfracFunc( } case 4: { // European 30/360 - const d1 = Math.min(s.getUTCDate(), 30); - const d2 = Math.min(e.getUTCDate(), 30); + const d1 = Math.min(s.getDate(), 30); + const d2 = Math.min(e.getDate(), 30); const days30 = - (e.getUTCFullYear() - s.getUTCFullYear()) * 360 + - (e.getUTCMonth() - s.getUTCMonth()) * 30 + + (e.getFullYear() - s.getFullYear()) * 360 + + (e.getMonth() - s.getMonth()) * 30 + (d2 - d1); return numNode(days30 / 360); } @@ -897,13 +900,39 @@ 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; } +/** + * `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. + */ +export 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); + } + const date = new Date(year, month - 1, day); + // Two-digit years must not be mapped to 19xx by the Date constructor. + date.setFullYear(year); + return date; + } + return new Date(text); +} + /** * `parseDateTime` parses either a full datetime/date value or a time literal. */ @@ -935,7 +964,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..72ff9e83d 100644 --- a/packages/office-sheets/src/formula/functions-financial.ts +++ b/packages/office-sheets/src/formula/functions-financial.ts @@ -1675,7 +1675,9 @@ 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; + // Rounded: two local midnights either side of a DST change are not a whole + // number of 24-hour days apart. + const actualDays = Math.round((ed.getTime() - sd.getTime()) / 86400000); 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..6296d0242 --- /dev/null +++ b/packages/office-sheets/test/formula/date-timezone.test.ts @@ -0,0 +1,51 @@ +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 = ['America/New_York', 'America/Los_Angeles', '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('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'); + }); + }); +} From dc95cca50f2a21f0959aa6b80e6ad438dd0553f0 Mon Sep 17 00:00:00 2001 From: HEY Research Lab <327801004+hey-research-lab@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:49:34 +0800 Subject: [PATCH 2/2] fix(sheets): address review on calendar date parsing - NETWORKDAYS and NETWORKDAYS.INTL compare calendar days, so a day with no local midnight (America/Santiago, 2019-09-08) no longer drops the last day of the range. - Financial day counts round only between two date-only values; a timestamp keeps its time of day as before. - YEARFRAC bases 0 and 4 read a date-only value by its calendar day and a timestamp by its UTC day as before, so an explicit offset gives the same result in every zone. - parseDateString is module-private again (lint:unused). - A date-only value sets year, month and day together, so 0000-02-29 keeps its leap day. The time-zone tests now also run in UTC, America/Santiago and Asia/Kolkata and cover each case. --- docs/office-engines/CHANGES.md | 8 +- .../src/formula/functions-date.ts | 84 ++++++++++++------- .../src/formula/functions-financial.ts | 11 ++- .../test/formula/date-timezone.test.ts | 57 ++++++++++++- 4 files changed, 125 insertions(+), 35 deletions(-) diff --git a/docs/office-engines/CHANGES.md b/docs/office-engines/CHANGES.md index bed895018..20d502610 100644 --- a/docs/office-engines/CHANGES.md +++ b/docs/office-engines/CHANGES.md @@ -8,8 +8,12 @@ Modification dates: 2026-09-06 through 2026-09-13. Original-file hashes are in s - 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, YEARFRAC and WEEKNUM count local calendar days; - `test/formula/date-timezone.test.ts` runs them in zones either side of UTC. + 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 diff --git a/packages/office-sheets/src/formula/functions-date.ts b/packages/office-sheets/src/formula/functions-date.ts index 8e3e4c35e..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++; @@ -415,10 +415,11 @@ function networkdaysintlFunc( } } - 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)) { + while ((direction > 0 && calendarDay(current) <= endDay) || (direction < 0 && calendarDay(current) >= endDay)) { if (!weekendDays.has(current.getDay()) && !holidays.has(formatDate(current))) { count++; } @@ -773,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, ); @@ -780,20 +790,17 @@ function yearfracFunc( switch (basis) { case 0: { // US (NASD) 30/360 - let d1 = s.getDate(); - let d2 = e.getDate(); - const m1 = s.getMonth() + 1; - const m2 = e.getMonth() + 1; - const y1 = s.getFullYear(); - const y2 = e.getFullYear(); - const isLastDayOfFeb = (d: Date): boolean => { - const y = d.getFullYear(); - const m = d.getMonth(); - return m === 1 && d.getDate() === 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; @@ -802,12 +809,9 @@ function yearfracFunc( } case 4: { // European 30/360 - const d1 = Math.min(s.getDate(), 30); - const d2 = Math.min(e.getDate(), 30); - const days30 = - (e.getFullYear() - s.getFullYear()) * 360 + - (e.getMonth() - s.getMonth()) * 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: { @@ -907,6 +911,28 @@ export function parseDate( 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. @@ -916,7 +942,7 @@ export function parseDate( * (`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. */ -export function parseDateString(text: string): Date { +function parseDateString(text: string): Date { const isoDate = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(text); if (isoDate) { const year = Number(isoDate[1]); @@ -925,9 +951,11 @@ export function parseDateString(text: string): Date { if (month < 1 || month > 12 || day < 1 || day > 31) { return new Date(NaN); } - const date = new Date(year, month - 1, day); - // Two-digit years must not be mapped to 19xx by the Date constructor. - date.setFullYear(year); + // 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); diff --git a/packages/office-sheets/src/formula/functions-financial.ts b/packages/office-sheets/src/formula/functions-financial.ts index 72ff9e83d..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,9 +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(); - // Rounded: two local midnights either side of a DST change are not a whole - // number of 24-hour days apart. - const actualDays = Math.round((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 index 6296d0242..60734ccb8 100644 --- a/packages/office-sheets/test/formula/date-timezone.test.ts +++ b/packages/office-sheets/test/formula/date-timezone.test.ts @@ -5,7 +5,14 @@ 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 = ['America/New_York', 'America/Los_Angeles', 'Pacific/Auckland']; +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}`, () => { @@ -41,6 +48,54 @@ for (const zone of zones) { 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);