From e88d3d75831a8b09cf6a6671bc5a357fa34c5868 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:36:51 +0200 Subject: [PATCH] EDATE and EOMONTH: serial 0 is day 0 and February 1900 has 28 days (fixes #734) Excel's EDATE and EOMONTH read serial 0 as 0 January 1900 and end February 1900 on the 28th (serial 59), although the sheet itself keeps the Lotus 29 February 1900 (serial 60). GridCraft clamped day 0 up to day 1 and used the sheet's 29-day February, so EDATE(0,0) was 1, EDATE(0,1) 32, EDATE(31,1) and EOMONTH(59,0) 60. EDATE now keeps day 0 and both functions take the real calendar's month lengths. Serial 60 still reads as 29 February, so EDATE(60,1) stays 29 March. DATE, DAY and the parser keep the leap day. Tests: the Excel 365 table from the issue; the #124 cases that expected 60 at the end of February 1900 now expect 59. --- crates/functions/src/date.rs | 39 +++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 29aeb47..2c1e2fd 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -1,6 +1,6 @@ //! Date and time functions. -use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd}; +use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd}; use gridcraft_core::parse::parse_input; use gridcraft_core::{CellError, DateSystem, Value}; @@ -164,7 +164,9 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64); + // Excel keeps serial 0's day 0 and gives February 1900 its real 28 days here, though the + // sheet's calendar has a 29 February 1900 (serial 60). + let dd = (d.day as i64).min(days_in_month(y as i32, mo as u32) as i64); num_val(ymd_serial(sys, y, mo, dd)? as f64) } @@ -177,7 +179,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64) + // Like EDATE, February 1900 ends on the 28th (serial 59). + num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64) } fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -680,17 +683,35 @@ mod tests { close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0); close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0); is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value); - // EDATE and EOMONTH land on it as the end of February 1900. - close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0); - close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0); - close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0); + // EDATE and EOMONTH read it as 29 February, but end February 1900 on the 28th. + close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 59.0); + close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 59.0); + close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 59.0); close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); - close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0); - close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0); + close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 59.0); + close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 59.0); close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0)); close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); } + #[test] + fn edate_eomonth_early_1900() { + // Excel 365: serial 0 is 0 January 1900 and February 1900 has 28 days here. + close(ev("EDATE", vec![n(0.0), n(0.0)]), 0.0); + close(ev("EDATE", vec![n(0.0), n(1.0)]), 31.0); + close(ev("EDATE", vec![n(0.0), n(12.0)]), 366.0); + close(ev("EOMONTH", vec![n(0.0), n(1.0)]), 59.0); + close(ev("EDATE", vec![n(31.0), n(1.0)]), 59.0); + close(ev("EOMONTH", vec![n(59.0), n(0.0)]), 59.0); + // Unchanged, and the same as Excel. + close(ev("EOMONTH", vec![n(0.0), n(0.0)]), 31.0); + is_err(ev("EOMONTH", vec![n(0.0), n(-1.0)]), CellError::Num); + close(ev("EDATE", vec![n(59.0), n(1.0)]), 88.0); + close(ev("EDATE", vec![n(60.0), n(1.0)]), 89.0); + close(ev("EDATE", vec![n(61.0), n(-1.0)]), 32.0); + close(ev("EOMONTH", vec![n(31.0), n(0.0)]), 31.0); + } + #[test] fn datedif_units() { let s = n(d(2001.0, 6.0, 1.0));