From ff73be326533b0c2178832325598bf94120701fd Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:17:11 -0500 Subject: [PATCH 1/3] Fix EDATE clamping the day to the 28th in a leap-year February EDATE clamped the shifted date's day with a static month-length table in which February always has 28 days, so any result landing in February of a leap year was one day short: =EDATE(DATE(2028, 1, 31), 1) returned 2028-02-28 instead of 2028-02-29. Move truncateDayInMonth onto DateTimeHelper so it can use the existing leap-aware daysInMonth(), which also honours the leapYear1900 option. EOMONTH already went through endOfMonth() and was unaffected. Co-Authored-By: Claude Fable 5.1 --- src/DateTimeHelper.ts | 12 ++++++++---- src/interpreter/plugin/DateTimePlugin.ts | 5 ++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/DateTimeHelper.ts b/src/DateTimeHelper.ts index aab02290c5..f35c66f8dc 100644 --- a/src/DateTimeHelper.ts +++ b/src/DateTimeHelper.ts @@ -176,6 +176,14 @@ export class DateTimeHelper { return {year: date.year, month: date.month, day: this.daysInMonth(date.year, date.month)} } + /** + * Clamps the day of the month so that it does not exceed the number of days in that month. + * Leap years, including the configurable 1900 leap year, are taken into account. + */ + public truncateDayInMonth(date: SimpleDate): SimpleDate { + return {year: date.year, month: date.month, day: Math.min(date.day, this.daysInMonth(date.year, date.month))} + } + public toBasisUS(start: SimpleDate, end: SimpleDate): [SimpleDate, SimpleDate] { if (start.day === 31) { start.day = 30 @@ -288,10 +296,6 @@ export function offsetMonth(date: SimpleDate, offset: number): SimpleDate { return {year: Math.floor(totalM / 12), month: totalM % 12 + 1, day: date.day} } -export function truncateDayInMonth(date: SimpleDate): SimpleDate { - return {year: date.year, month: date.month, day: Math.min(date.day, numDays[date.month - 1])} -} - export function roundToNearestSecond(arg: number): number { return Math.round(arg * 3600 * 24) / (3600 * 24) } diff --git a/src/interpreter/plugin/DateTimePlugin.ts b/src/interpreter/plugin/DateTimePlugin.ts index fd1d3fad01..a3dad74cb6 100644 --- a/src/interpreter/plugin/DateTimePlugin.ts +++ b/src/interpreter/plugin/DateTimePlugin.ts @@ -12,8 +12,7 @@ import { roundToNearestSecond, SimpleDate, timeToNumber, - toBasisEU, - truncateDayInMonth + toBasisEU } from '../../DateTimeHelper' import {ErrorMessage} from '../../error-message' import {format} from '../../format/format' @@ -458,7 +457,7 @@ export class DateTimePlugin extends FunctionPlugin implements FunctionPluginType return this.runFunction(ast.args, state, this.metadata('EDATE'), (dateNumber: number, delta: number) => { const date = this.dateTimeHelper.numberToSimpleDate(dateNumber) - const newDate = truncateDayInMonth(offsetMonth(date, delta)) + const newDate = this.dateTimeHelper.truncateDayInMonth(offsetMonth(date, delta)) let ret: Maybe = this.dateTimeHelper.dateToNumber(newDate) ret = this.dateTimeHelper.getWithinBounds(ret) if (ret === undefined) { From 977db634ee2e702e4dc14a79b9be5dae5ce50e8e Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:18:26 -0500 Subject: [PATCH 2/3] Add changelog entry for the EDATE leap-year fix Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdba941dd..c35ce36c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Fixed the validation of classic (25-character) license keys depending on the time zone: east of UTC, a key that expired the day before the build was released was still accepted, and west of UTC, the console message printed an expiry date one day too early. [#1728](https://github.com/handsontable/hyperformula/pull/1728) +- Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) From 09df59883176d2131d6b0db16a1b9edcf0fea1c8 Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:47:37 -0400 Subject: [PATCH 3/3] Document the EDATE and EOMONTH February 1900 difference from Excel With leapYear1900 enabled, HyperFormula treats February 1900 as having 29 days, so EDATE and EOMONTH can return 29 February 1900. Excel keeps the 1900 leap day only in its serial-to-date mapping and returns 28 February from both functions. Record this in the list of differences. Co-Authored-By: Claude Fable 5.1 --- docs/guide/list-of-differences.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index a109f82616..660fda0f98 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -51,6 +51,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | TIMEVALUE function | =TIMEVALUE("14:31") | Type of the returned value: `CellValueDetailedType.NUMBER_TIME` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** | | EDATE function | =EDATE(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | | EOMONTH function | =EOMONTH(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | +| EDATE and EOMONTH results in February 1900 | =EDATE(DATE(1900, 1, 31), 1)

=EOMONTH(DATE(1900, 1, 31), 1) | With the [`leapYear1900`](/api/interfaces/configparams.md#leapyear1900) option set to `true`, February 1900 has 29 days, so both examples return 29 February 1900 (serial `60` with `nullDate` set to 1899-12-31). With the default `false`, both return 28 February 1900. | 28 February 1900 for both examples. Google Sheets does not treat 1900 as a leap year. | 28 February 1900 (serial `59`) for both examples. Excel keeps the 1900 leap day only in the serial-to-date mapping (`DAY(60)` is `29`); its month arithmetic treats February 1900 as having 28 days, so no `EDATE` or `EOMONTH` result is ever serial `60`. | | Empty cells in lookup search | =XLOOKUP(3, A1:A4, A1:A4, "NF", 0, 2)
where A1:A4 = 1, 2, (empty), 3 | Empty cells are skipped during the search. A value that is present is found even when it sits past an interspersed empty cell (exact match is gap-independent), and approximate `MATCH`/`VLOOKUP`/`HLOOKUP`/`XLOOKUP` skip empty cells — but not empty strings — when finding the lower/upper bound. Returns `3`. On an all-empty range in a binary search mode, HyperFormula returns the `if_not_found` result (never row 1). | Skips empty cells in approximate search (parity with HyperFormula). | With binary search modes (`search_mode` ±2), a range with interspersed empty cells is not strictly sorted; per Excel's documentation the result may be invalid, so a value past an empty cell is not reliably found. On an all-empty range in a binary mode, Excel returns the first row's value. | ## Built-in functions