Files
gridcraft/crates/core/src/date.rs
T
NetterSilverandClaude Haiku 5.5 960c64c3e4 Return None from datetime_from_serial when rounding carries past 9999
Found while testing crates/core/src/date.rs:168-177

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:19:51 +00:00

388 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Date serial numbers.
//!
//! The 1900 system counts days from 1899-12-31 (serial 1 = 1900-01-01) and, like Lotus 1-2-3 and
//! Excel, treats 1900 as a leap year: serial 60 is the fictitious 1900-02-29. The 1904 system
//! counts from 1904-01-01 (serial 0). Times are fractions of a day.
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum DateSystem {
#[default]
D1900,
D1904,
}
/// A calendar date and time of day.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct DateTime {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub second: u32,
/// Milliseconds (rounded).
pub milli: u32,
/// 0 = Sunday.
pub weekday: u32,
}
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
///
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
i32::try_from(year)
.ok()
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
.and_then(|date| {
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
i64::try_from(days).ok()
})
.unwrap_or(0)
}
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`.
#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm
pub fn unix_now_ms() -> Option<f64> {
#[cfg(not(target_arch = "wasm32"))]
{
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?;
Some(d.as_secs_f64() * 1000.0)
}
#[cfg(target_arch = "wasm32")]
{
Some(js_sys::Date::now())
}
}
/// Date serial (1900 system, with fraction) for a Unix time in milliseconds.
pub fn serial_from_unix_ms(ms: f64) -> f64 {
25569.0 + ms / 86_400_000.0
}
/// Converts a 1900-system serial (as the clock supplies it) to the serial of the same instant in `sys`.
pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
match sys {
DateSystem::D1900 => serial_1900,
DateSystem::D1904 => serial_1900 - 1462.0,
}
}
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
/// [`days_from_civil`]).
///
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
/// 1970-01-01 is `num_days_from_ce` 719_163.
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
}
// Unreachable: `rest + 719_163` is always a valid CE day.
None => (1970, 1, 1),
}
}
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
const UNIX_1904_01_01: i64 = -24107;
/// Whether `y` is a Gregorian leap year, for every `i32` year.
pub fn is_leap(y: i32) -> bool {
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
let y = i64::from(y).rem_euclid(400) as i32;
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
}
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
/// `i32` year.
pub fn days_in_month(y: i32, m: u32) -> u32 {
// The length of a month is the day before the first of the next month. Month lengths repeat
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
if !(1..=12).contains(&m) {
return 0;
}
let y = i64::from(y).rem_euclid(400) as i32;
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
_ => 0,
}
}
/// 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) }
}
/// Last serial that is a valid date in a date system: 9999-12-31 (2958465 in the 1900 system, 2957003 in the 1904 system).
pub fn max_serial(sys: DateSystem) -> i64 {
match sys {
DateSystem::D1900 => 2_958_465,
DateSystem::D1904 => 2_958_465 - 1462,
}
}
/// 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), 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 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 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..=max_serial(sys)).contains(&serial) {
return None;
}
Some(serial as f64)
}
/// Date/time from a serial. `None` for negative or too-large serials.
pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
if !serial.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&serial) {
return None;
}
let mut day = serial.floor() as i64;
let mut frac_ms = ((serial - serial.floor()) * 86_400_000.0).round() as i64;
if frac_ms >= 86_400_000 {
frac_ms -= 86_400_000;
day += 1;
if day > max_serial(sys) {
return None;
}
}
let (y, m, d, weekday) = match sys {
DateSystem::D1900 => {
// Excel's weekdays continue the fictitious calendar: serial 1 is a Sunday.
let wd = ((day + 6) % 7) as u32;
if day == 60 {
(1900, 2, 29, wd)
} else if day == 0 {
(1900, 1, 0, wd)
} else {
let real = if day < 60 { day + 1 } else { day };
let (y, m, d) = civil_from_days(real + UNIX_1899_12_30);
(y, m, d, wd)
}
}
DateSystem::D1904 => {
let unix = day + UNIX_1904_01_01;
let (y, m, d) = civil_from_days(unix);
(y, m, d, ((unix + 4).rem_euclid(7)) as u32)
}
};
let secs = frac_ms / 1000;
Some(DateTime {
year: y,
month: m,
day: d,
hour: (secs / 3600) as u32,
minute: ((secs / 60) % 60) as u32,
second: (secs % 60) as u32,
milli: (frac_ms % 1000) as u32,
weekday,
})
}
/// Fraction of a day for a time.
pub fn time_fraction(h: f64, m: f64, s: f64) -> f64 {
(h * 3600.0 + m * 60.0 + s) / 86400.0
}
pub const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serial_rounding_into_next_day_past_max_is_none() {
for sys in [DateSystem::D1900, DateSystem::D1904] {
let serial = max_serial(sys) as f64 + 0.9999999995;
assert_eq!(datetime_from_serial(sys, serial), None);
}
}
#[test]
fn leap_and_month_length_outside_chrono_range() {
// In range, the ordinary Gregorian rule holds.
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
assert!(is_leap(400_000)); // divisible by 400
assert!(!is_leap(300_100)); // century not divisible by 400
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
assert_eq!(days_in_month(400_000, 2), 29);
assert_eq!(days_in_month(300_100, 2), 28);
assert_eq!(days_in_month(400_000, 1), 31);
assert_eq!(days_in_month(400_000, 4), 30);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2023, 0), 0);
assert_eq!(days_in_month(2023, 13), 0);
// December at i32::MAX does not overflow and still reports 31 days.
assert_eq!(days_in_month(i32::MAX, 12), 31);
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
}
#[test]
fn civil_conversions_exact_beyond_chrono_range() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(civil_from_days(0), (1970, 1, 1));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
// the epoch; values round-trip through the proleptic Gregorian converters.
for (y, m, d, z) in [
(262_144i32, 1u32, 1u32, 95_026_602i64),
(300_000, 1, 1, 108_853_222),
(400_000, 2, 29, 145_377_531),
(-262_145, 3, 1, -96_465_964),
(-300_000, 6, 15, -110_292_112),
] {
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
}
// The extremes must not overflow or panic (i128 intermediates).
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
}
#[test]
fn serials_unchanged_through_cycle_conversion() {
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
assert_eq!(days_in_month_in(s, 1900, 2), 29);
let s4 = DateSystem::D1904;
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
}
#[test]
fn serial_from_unix_time() {
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
// 2026-10-10T12:00:00Z
assert_eq!(serial_from_unix_ms(1_791_633_600_000.0), 46305.5);
assert!(unix_now_ms().is_some_and(|ms| serial_from_unix_ms(ms) > 46305.0));
}
#[test]
fn known_serials() {
let s = DateSystem::D1900;
assert_eq!(serial_from_ymd(s, 1900, 1, 1), Some(1.0));
assert_eq!(serial_from_ymd(s, 1900, 2, 28), Some(59.0));
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0));
assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0));
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 31), Some(2957003.0));
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 32), None);
assert!(datetime_from_serial(DateSystem::D1904, 2957003.5).is_some());
assert!(datetime_from_serial(DateSystem::D1904, 2957004.0).is_none());
assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1));
assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29));
assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1));
assert_eq!(serial_from_ymd(DateSystem::D1904, 1904, 1, 1), Some(0.0));
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, 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));
}
let d = datetime_from_serial(s, 60.0).unwrap();
assert_eq!((d.year, d.month, d.day), (1900, 2, 29));
let d = datetime_from_serial(s, 46302.75).unwrap();
assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday
let d = datetime_from_serial(s, 1.0).unwrap();
assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar
}
}