mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
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>
388 lines
17 KiB
Rust
388 lines
17 KiB
Rust
//! 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
|
||
}
|
||
}
|