DATE(1900,2,29) is serial 60, as in Excel

serial_from_ymd worked out the real calendar date and then moved serials
up to 60 down by one. February 1900 has no 29th in the real calendar, so
DATE(1900,2,29) and DATEVALUE("2/29/1900") rolled over to 1 March (61),
and DATE(1900,3,0) gave 28 February (59). Excel's 1900 system keeps the
Lotus leap day: serial 60 is 29 February 1900, and DAY(60) already
said so.

The serial of the first of the month is now taken in Excel's calendar
(one lower up to February 1900), and the day, with any overflow, counts
on from there. So day and month overflow pass through serial 60 both
ways: DATE(1900,1,60), DATE(1900,4,-31) and DATE(1901,-10,29) are 60 too.
Month and day arithmetic is checked, so hostile values return None.

days_in_month_in gives February 1900 29 days in the 1900 system; EDATE
and EOMONTH use it, so EOMONTH(60,0) is 60 rather than the day before,
and the text parser uses it in place of its own special case. The 1904
system is unchanged (1900 is before its epoch).

Tests: serials around the leap day (overflow both ways, every serial
0-366 round-trips through its year, month and day), hostile months and
days, parsing 2/29/1900 and 1900-02-29, DATE/DATEVALUE/EDATE/EOMONTH
in the 1900 system and the 1904 system. The DATE, DATEVALUE and serial
cases fail before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lloyd
2026-10-09 20:36:08 +02:00
co-authored by Claude Opus 5.5
parent 51b531be3e
commit ecaead4c12
3 changed files with 129 additions and 17 deletions
+69 -13
View File
@@ -69,26 +69,34 @@ pub fn days_in_month(y: i32, m: u32) -> u32 {
}
}
/// Days in a month of a date system's calendar: like [`days_in_month`], except that February
/// 1900 has 29 days in the 1900 system (serial 60 is the fictitious 1900-02-29).
pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 {
if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) }
}
/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next
/// year, day 0 = last day of the previous month). `None` before the epoch or after 9999-12-31.
/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900
/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60.
/// `None` before the epoch or after 9999-12-31.
pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option<f64> {
// DATE(): years 0–1899 are added to 1900.
let mut y = if (0..1900).contains(&year) { year + 1900 } else { year };
let m0 = month - 1;
y += m0.div_euclid(12);
let y = if (0..1900).contains(&year) { year + 1900 } else { year };
let m0 = month.checked_sub(1)?;
let y = y.checked_add(m0.div_euclid(12))?;
let m = m0.rem_euclid(12) + 1;
if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 {
return None;
}
let base = days_from_civil(y as i32, m as u32, 1) + day - 1;
let serial = match sys {
DateSystem::D1900 => {
let s = base - UNIX_1899_12_30;
// Before the fictitious 1900-02-29 serials are one lower than the day count from 1899-12-30.
if s <= 60 { s - 1 } else { s }
}
DateSystem::D1904 => base - UNIX_1904_01_01,
let first = days_from_civil(y as i32, m as u32, 1);
// Serial of the first of the month; the day (and any overflow) counts on from there.
let first = match sys {
// Up to February 1900 the serial is one lower than the day count from 1899-12-30: the
// fictitious 1900-02-29 (serial 60) comes before 1 March.
DateSystem::D1900 => first - UNIX_1899_12_30 - i64::from(y == 1900 && m <= 2),
DateSystem::D1904 => first - UNIX_1904_01_01,
};
let serial = first.checked_add(day)?.checked_sub(1)?;
if !(0..=2_958_465).contains(&serial) {
return None;
}
@@ -168,10 +176,58 @@ mod tests {
assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0));
}
#[test]
fn fictitious_1900_02_29() {
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
// through it.
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 3, 0), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 2, 30), Some(61.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 60), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 61), Some(61.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 400), Some(400.0));
assert_eq!(serial_from_ymd(s, 1900, 4, -30), Some(61.0));
assert_eq!(serial_from_ymd(s, 1900, 4, -31), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 3, -1), Some(59.0));
assert_eq!(serial_from_ymd(s, 1901, -10, 29), Some(60.0));
assert_eq!(serial_from_ymd(s, 1900, 1, 0), Some(0.0));
assert_eq!(serial_from_ymd(s, 1900, 1, -1), None);
assert_eq!(serial_from_ymd(s, 1900, 0, 1), None);
// Later years keep the real calendar.
assert_eq!(serial_from_ymd(s, 1901, 2, 29), serial_from_ymd(s, 1901, 3, 1));
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0));
// Every serial up to 1900-12-31 comes back from its own year, month and day.
for serial in 0..=366 {
let d = datetime_from_serial(s, serial as f64).unwrap();
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}");
}
assert_eq!(days_in_month_in(s, 1900, 2), 29);
assert_eq!(days_in_month_in(s, 1901, 2), 28);
assert_eq!(days_in_month_in(DateSystem::D1904, 1900, 2), 28);
// The 1904 system has no fictitious day: 1904 is a real leap year.
let s = DateSystem::D1904;
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(59.0));
assert_eq!(serial_from_ymd(s, 1904, 3, 0), Some(59.0));
assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0));
assert_eq!(serial_from_ymd(s, 1904, 1, 0), None);
assert_eq!(serial_from_ymd(s, 1900, 2, 29), None);
}
#[test]
fn hostile_month_and_day() {
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 2000, i64::MIN, 1), None);
assert_eq!(serial_from_ymd(s, 2000, i64::MAX, 1), None);
assert_eq!(serial_from_ymd(s, 2000, 1, i64::MAX), None);
assert_eq!(serial_from_ymd(s, 2000, 1, i64::MIN), None);
assert_eq!(serial_from_ymd(s, i64::MAX, 1, 1), None);
}
#[test]
fn roundtrip() {
let s = DateSystem::D1900;
for serial in [1.0, 59.0, 61.0, 36526.0, 46302.0, 2958465.0] {
for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] {
let d = datetime_from_serial(s, serial).unwrap();
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial));
}
+16 -1
View File
@@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
if !(1..=12).contains(&m) || d < 1 {
return None;
}
let max = if y == 1900 && m == 2 { 29 } else { crate::date::days_in_month(y as i32, m as u32) as i64 };
let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64;
if d > max {
return None;
}
@@ -357,4 +357,19 @@ mod tests {
assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75));
assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0));
}
#[test]
fn fictitious_1900_02_29() {
// Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61.
assert_eq!(p("2/29/1900").value, Value::Number(60.0));
assert_eq!(p("1900-02-29").value, Value::Number(60.0));
assert_eq!(p("29-Feb-1900").value, Value::Number(60.0));
assert_eq!(p("2/28/1900").value, Value::Number(59.0));
assert_eq!(p("3/1/1900").value, Value::Number(61.0));
assert_eq!(p("2/29/1901").value, Value::text("2/29/1901"));
assert_eq!(p("2/30/1900").value, Value::text("2/30/1900"));
// The 1904 system starts after it.
assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
}
}
+44 -3
View File
@@ -1,6 +1,6 @@
//! Date and time functions.
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd};
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd};
use gridcraft_core::parse::parse_input;
use gridcraft_core::{CellError, DateSystem, Value};
@@ -164,7 +164,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
if !(1900..=9999).contains(&y) {
return Err(CellError::Num);
}
let dd = (d.day.max(1) as i64).min(days_in_month(y as i32, mo as u32) as i64);
let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64);
num_val(ymd_serial(sys, y, mo, dd)? as f64)
}
@@ -177,7 +177,7 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
if !(1900..=9999).contains(&y) {
return Err(CellError::Num);
}
num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64)
num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64)
}
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
@@ -652,6 +652,36 @@ mod tests {
is_err(ev("EDATE", vec![n(1.0), n(1e12)]), CellError::Num);
}
#[test]
fn fictitious_1900_02_29() {
// Excel's 1900 calendar has 29 February 1900 = serial 60, and DATE counts day and month
// overflow through it.
assert_eq!(d(1900.0, 2.0, 29.0), 60.0);
assert_eq!(d(1900.0, 3.0, 0.0), 60.0);
assert_eq!(d(1900.0, 2.0, 28.0), 59.0);
assert_eq!(d(1900.0, 3.0, 1.0), 61.0);
assert_eq!(d(1900.0, 2.0, 30.0), 61.0);
assert_eq!(d(1900.0, 1.0, 60.0), 60.0);
assert_eq!(d(1900.0, 4.0, -31.0), 60.0);
assert_eq!(d(1901.0, -10.0, 29.0), 60.0);
assert_eq!(d(0.0, 2.0, 29.0), 60.0);
assert_eq!(d(1900.0, 1.0, 0.0), 0.0);
close(ev("DAY", vec![n(d(1900.0, 3.0, 0.0))]), 29.0);
close(ev("DATEVALUE", vec![t("1900-02-29")]), 60.0);
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);
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(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 datedif_units() {
let s = n(d(2001.0, 6.0, 1.0));
@@ -755,5 +785,16 @@ mod tests {
close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0);
let spec = crate::lookup("YEAR").unwrap();
close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0);
// No fictitious day here: 1904 is a real leap year and 1900 is before the epoch.
let spec = crate::lookup("DATE").unwrap();
close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0);
close(crate::call(spec, &[n(1904.0), n(3.0), n(0.0)], &mut ctx), 59.0);
close(crate::call(spec, &[n(1904.0), n(3.0), n(1.0)], &mut ctx), 60.0);
is_err(crate::call(spec, &[n(1900.0), n(2.0), n(29.0)], &mut ctx), CellError::Num);
let spec = crate::lookup("EOMONTH").unwrap();
close(crate::call(spec, &[n(31.0), n(0.0)], &mut ctx), 59.0);
let spec = crate::lookup("DATEVALUE").unwrap();
close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0);
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
}
}