diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bdba941d..c35ce36c3 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)
diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md
index a109f8261..660fda0f9 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
diff --git a/src/DateTimeHelper.ts b/src/DateTimeHelper.ts
index aab02290c..f35c66f8d 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 fd1d3fad0..a3dad74cb 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) {