Skip to content

Fix EDATE returning 28 February instead of 29 February in leap years - #1781

Open
GreenFlux wants to merge 3 commits into
developfrom
fix/edate-leap-year-february
Open

GreenFlux wants to merge 3 commits into
developfrom
fix/edate-leap-year-february

Conversation

@GreenFlux

@GreenFlux GreenFlux commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Context

EDATE clamps the day of the shifted date so it does not exceed the length of the target month, but the clamp used a static month-length table in which February always has 28 days. Any result that landed in February of a leap year was therefore one day short:

Formula Before After (matches OpenDocument 6.10.8, Excel, Google Sheets)
=EDATE(DATE(2028, 1, 31), 1) 2028-02-28 2028-02-29
=EDATE(DATE(2028, 3, 31), -1) 2028-02-28 2028-02-29
=EDATE(DATE(2028, 1, 29), 1) 2028-02-28 2028-02-29
=EDATE(DATE(2000, 1, 31), 1) 2000-02-28 2000-02-29
=EDATE(DATE(2100, 1, 31), 1) 2100-02-28 2100-02-28 (unchanged)
=EOMONTH(DATE(2028, 1, 31), 1) 2028-02-29 2028-02-29 (unchanged)

The fix moves truncateDayInMonth from a module-level function onto DateTimeHelper, where it can call the existing leap-aware daysInMonth(). That helper already honours the leapYear1900 option, so with that option on, =EDATE(DATE(1900, 1, 31), 1) now returns 29 February 1900 (serial 60 with nullDate 1899-12-31), the same result EOMONTH already gave for that month. This is HyperFormula's own leapYear1900 model, not Excel behaviour: Excel keeps the 1900 leap day only in its serial-to-date mapping (DAY(60) is 29) and its month arithmetic treats February 1900 as 28 days, so Excel returns 28 February (serial 59) for both functions. The difference is now recorded in docs/guide/list-of-differences.md, covering EDATE and EOMONTH. EOMONTH already used daysInMonth() through endOfMonth() and is unchanged. The removed function was not exported from the package entry point, so there is no public API change.

How did you test your changes?

  • New specs in the paired hyperformula-tests branch (fix/edate-leap-year-february): handsontable/hyperformula-tests#67. They cover forward and backward shifts into a leap-year February, the 29th and 30th day boundaries, the 400-year and 100-year rules, a leap day shifted into a non-leap year, and both leapYear1900 settings. Six of the nine fail on develop and all pass with this change.
  • function-edate, function-eomonth, and date specs: 67 passed.
  • npm run lint: 0 errors, no new warnings. npm run verify:typings: clean.
  • Verified the table above by evaluating the formulas against the patched src/ with ts-node.

Types of changes

  • Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore)
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues:

  1. No tracked issue; found while comparing EDATE output against other spreadsheet implementations.

Checklist:

  • I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project.
  • I have signed the Contributor License Agreement.
  • My change is compliant with the OpenDocument standard.
  • My change is compatible with Microsoft Excel.
  • My change is compatible with Google Sheets.
  • I described my changes in the CHANGELOG.md file.
  • My changes require a documentation update.
  • My changes require a migration guide.

🤖 Generated with Claude Code


Note

Low Risk
Localized date arithmetic bug fix with no public API change; behavior change only for EDATE results landing in leap-year February (and 1900 when leapYear1900 is true).

Overview
Fixes EDATE so month shifts that land in a leap-year February clamp to 29 days instead of always using 28 (e.g. =EDATE(DATE(2028, 1, 31), 1) → 2028-02-29).

truncateDayInMonth moves from a module helper onto DateTimeHelper and uses leap-aware daysInMonth() (including leapYear1900) instead of a static February length. EDATE in DateTimePlugin calls the instance method; EOMONTH is unchanged.

Docs: CHANGELOG entry and a list-of-differences row for February 1900 EDATE/EOMONTH vs Excel/Google Sheets when leapYear1900 is enabled.

Reviewed by Cursor Bugbot for commit 09df598. Bugbot is set up for automated code reviews on this repo. Configure here.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
hyperformula-docs 09df598 Commit Preview URL

Branch Preview URL
Oct 08 2026, 03:07 PM

@GreenFlux
GreenFlux marked this pull request as ready for review September 29, 2026 10:22
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Performance comparison of head (09df598) vs base (eff7928)

                                     testName |    base |   head | change
-------------------------------------------------------------------------
                                      Sheet A |  381.41 | 388.12 | +1.76%
                                      Sheet B |  124.25 | 125.44 | +0.96%
                                      Sheet T |  107.05 | 109.89 | +2.65%
                                Column ranges |  410.64 | 410.37 | -0.07%
                                Sorted lookup | 12009.8 |  12485 | +3.96%
Sheet A:  change value, add/remove row/column |   12.91 |  12.51 | -3.10%
 Sheet B: change value, add/remove row/column |  113.83 | 111.27 | -2.25%
                   Column ranges - add column |  130.82 | 128.09 | -2.09%
                Column ranges - without batch |   412.9 | 396.44 | -3.99%
                        Column ranges - batch |  103.41 |  98.52 | -4.73%

@marcin-kordas-hoc marcin-kordas-hoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix looks right to me. The clamp now follows daysInMonth(), and with leapYear1900 off the results match a Gregorian oracle for every start date and offset I tried (27,811 combinations per nullDate setting, including negative offsets across 1900). develop misses 214 of them, all in leap-year Februaries. The paired specs fail 6 of 9 at the merge-base and pass here.

One thing before I approve: the sentence in the description saying 29 February 1900 matches Excel. Excel Online returns 28 February there (details inline). The 29 is HyperFormula's own leapYear1900 model, so I'd keep the code, change the sentence, and add a row to list-of-differences.md.

Comment thread src/DateTimeHelper.ts
* 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I measured this in Excel Online (not desktop). DAY(60) is 29, but no EDATE or EOMONTH result is ever serial 60. =EDATE(DATE(1900,1,31),1) gives 59 (28 Feb) and =EDATE(60,1) gives 89 (29 Mar), so only the February clamp is 28. Excel keeps the fake leap day in the serial-to-date mapping only. Its month arithmetic treats February 1900 as 28 days.

With leapYear1900: true and nullDate 1899-12-31, the setup the compatibility guide recommends, 16 of my 450 start/offset cases now return 60 where develop returned 59 (for example EDATE(DATE(1900,3,31),-1)). Another 161 moved closer to Excel.

I wouldn't special-case 1900. EOMONTH on develop already returns 60, and DATE(1900,2,29) is valid in that mode. A row in list-of-differences.md covering both functions should be enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, agreed on all points. I've kept the code as is, reworded the description so it no longer claims the 1900 result matches Excel, and added a row to list-of-differences.md covering both EDATE and EOMONTH under leapYear1900. The Excel values in that row are from your Excel Online measurement, so let me know if you'd rather phrase them differently.

I also rebased both branches onto develop to clear a changelog conflict; the earlier browser-test failures were the VERSION specs from the paired tests branch lagging behind the HF-307 test updates, not this change.

GreenFlux and others added 3 commits October 8, 2026 10:57
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@GreenFlux
GreenFlux force-pushed the fix/edate-leap-year-february branch from ce4de30 to 09df598 Compare October 8, 2026 15:02
@codecov

codecov Bot commented Oct 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.28%. Comparing base (eff7928) to head (09df598).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##           develop    #1781   +/-   ##
========================================
  Coverage    97.28%   97.28%           
========================================
  Files          207      207           
  Lines        16218    16218           
  Branches      3588     3588           
========================================
  Hits         15777    15777           
  Misses         433      433           
  Partials         8        8           
Files with missing lines Coverage Δ
src/DateTimeHelper.ts 96.19% <100.00%> (ø)
src/interpreter/plugin/DateTimePlugin.ts 99.10% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants