Skip to main content

ravenrs_sheet/base/
date.rs

1//! Date serial numbers.
2//!
3//! The 1900 system counts days from 1899-12-31 (serial 1 = 1900-01-01) and, like Lotus 1-2-3 and
4//! Excel, treats 1900 as a leap year: serial 60 is the fictitious 1900-02-29. The 1904 system
5//! counts from 1904-01-01 (serial 0). Times are fractions of a day.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
10pub enum DateSystem {
11    #[default]
12    D1900,
13    D1904,
14}
15
16/// A calendar date and time of day.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
18pub struct DateTime {
19    pub year: i32,
20    pub month: u32,
21    pub day: u32,
22    pub hour: u32,
23    pub minute: u32,
24    pub second: u32,
25    /// Milliseconds (rounded).
26    pub milli: u32,
27    /// 0 = Sunday.
28    pub weekday: u32,
29}
30
31/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
32///
33/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
34/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
35/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
36/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
37pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
38    let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
39    i32::try_from(year)
40        .ok()
41        .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
42        .and_then(|date| {
43            let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
44            i64::try_from(days).ok()
45        })
46        .unwrap_or(0)
47}
48
49/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
50/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap.
51pub fn unix_now_ms() -> Option<f64> {
52    #[cfg(not(target_arch = "wasm32"))]
53    {
54        Some(chrono::Utc::now().timestamp_millis() as f64)
55    }
56    #[cfg(target_arch = "wasm32")]
57    {
58        Some(js_sys::Date::now())
59    }
60}
61
62/// Date serial (1900 system, with fraction) for a Unix time in milliseconds.
63pub fn serial_from_unix_ms(ms: f64) -> f64 {
64    25569.0 + ms / 86_400_000.0
65}
66
67/// Converts a 1900-system serial (as the clock supplies it) to the serial of the same instant in `sys`.
68pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
69    match sys {
70        DateSystem::D1900 => serial_1900,
71        DateSystem::D1904 => serial_1900 - 1462.0,
72    }
73}
74
75/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
76/// [`days_from_civil`]).
77///
78/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
79/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
80/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
81/// 1970-01-01 is `num_days_from_ce` 719_163.
82pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
83    use chrono::Datelike;
84    let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
85    match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
86        Some(d) => {
87            let y = i128::from(d.year()) + i128::from(cycles) * 400;
88            (i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
89        }
90        // Unreachable: `rest + 719_163` is always a valid CE day.
91        None => (1970, 1, 1),
92    }
93}
94
95const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
96const UNIX_1904_01_01: i64 = -24107;
97
98/// Whether `y` is a Gregorian leap year, for every `i32` year.
99pub fn is_leap(y: i32) -> bool {
100    // A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
101    // the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
102    let y = i64::from(y).rem_euclid(400) as i32;
103    chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
104}
105
106/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
107/// `i32` year.
108pub fn days_in_month(y: i32, m: u32) -> u32 {
109    // The length of a month is the day before the first of the next month. Month lengths repeat
110    // every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
111    // always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
112    if !(1..=12).contains(&m) {
113        return 0;
114    }
115    let y = i64::from(y).rem_euclid(400) as i32;
116    let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
117    match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
118        (Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
119        _ => 0,
120    }
121}
122
123/// Days in a month of a date system's calendar: like [`days_in_month`], except that February
124/// 1900 has 29 days in the 1900 system (serial 60 is the fictitious 1900-02-29).
125pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 {
126    if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) }
127}
128
129/// Last serial that is a valid date in a date system: 9999-12-31 (2958465 in the 1900 system, 2957003 in the 1904 system).
130pub fn max_serial(sys: DateSystem) -> i64 {
131    match sys {
132        DateSystem::D1900 => 2_958_465,
133        DateSystem::D1904 => 2_958_465 - 1462,
134    }
135}
136
137/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next
138/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900
139/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60.
140/// `None` before the epoch or after 9999-12-31.
141pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option<f64> {
142    // DATE(): years 0–1899 are added to 1900.
143    let y = if (0..1900).contains(&year) { year + 1900 } else { year };
144    let m0 = month.checked_sub(1)?;
145    let y = y.checked_add(m0.div_euclid(12))?;
146    let m = m0.rem_euclid(12) + 1;
147    if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 {
148        return None;
149    }
150    let first = days_from_civil(y as i32, m as u32, 1);
151    // Serial of the first of the month; the day (and any overflow) counts on from there.
152    let first = match sys {
153        // Up to February 1900 the serial is one lower than the day count from 1899-12-30: the
154        // fictitious 1900-02-29 (serial 60) comes before 1 March.
155        DateSystem::D1900 => first - UNIX_1899_12_30 - i64::from(y == 1900 && m <= 2),
156        DateSystem::D1904 => first - UNIX_1904_01_01,
157    };
158    let serial = first.checked_add(day)?.checked_sub(1)?;
159    if !(0..=max_serial(sys)).contains(&serial) {
160        return None;
161    }
162    Some(serial as f64)
163}
164
165/// Date/time from a serial. `None` for negative or too-large serials.
166pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
167    if !serial.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&serial) {
168        return None;
169    }
170    let mut day = serial.floor() as i64;
171    let mut frac_ms = ((serial - serial.floor()) * 86_400_000.0).round() as i64;
172    if frac_ms >= 86_400_000 {
173        frac_ms -= 86_400_000;
174        day += 1;
175        if day > max_serial(sys) {
176            return None;
177        }
178    }
179    let (y, m, d, weekday) = match sys {
180        DateSystem::D1900 => {
181            // Excel's weekdays continue the fictitious calendar: serial 1 is a Sunday.
182            let wd = ((day + 6) % 7) as u32;
183            if day == 60 {
184                (1900, 2, 29, wd)
185            } else if day == 0 {
186                (1900, 1, 0, wd)
187            } else {
188                let real = if day < 60 { day + 1 } else { day };
189                let (y, m, d) = civil_from_days(real + UNIX_1899_12_30);
190                (y, m, d, wd)
191            }
192        }
193        DateSystem::D1904 => {
194            let unix = day + UNIX_1904_01_01;
195            let (y, m, d) = civil_from_days(unix);
196            (y, m, d, ((unix + 4).rem_euclid(7)) as u32)
197        }
198    };
199    let secs = frac_ms / 1000;
200    Some(DateTime {
201        year: y,
202        month: m,
203        day: d,
204        hour: (secs / 3600) as u32,
205        minute: ((secs / 60) % 60) as u32,
206        second: (secs % 60) as u32,
207        milli: (frac_ms % 1000) as u32,
208        weekday,
209    })
210}
211
212/// Fraction of a day for a time.
213pub fn time_fraction(h: f64, m: f64, s: f64) -> f64 {
214    (h * 3600.0 + m * 60.0 + s) / 86400.0
215}
216
217pub const MONTHS: [&str; 12] =
218    ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
219pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn serial_rounding_into_next_day_past_max_is_none() {
227        for sys in [DateSystem::D1900, DateSystem::D1904] {
228            let serial = max_serial(sys) as f64 + 0.9999999995;
229            assert_eq!(datetime_from_serial(sys, serial), None);
230        }
231    }
232
233    #[test]
234    fn leap_and_month_length_outside_chrono_range() {
235        // In range, the ordinary Gregorian rule holds.
236        assert!(is_leap(2000));
237        assert!(!is_leap(1900));
238        assert!(is_leap(2024));
239        assert!(!is_leap(2023));
240        // Outside chrono's NaiveDate range the Gregorian rule is still applied.
241        assert!(is_leap(400_000)); // divisible by 400
242        assert!(!is_leap(300_100)); // century not divisible by 400
243        assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
244        assert!(!is_leap(i32::MAX)); // 2147483647 is odd
245        // days_in_month keeps calendar lengths out of range and 0 for invalid months.
246        assert_eq!(days_in_month(400_000, 2), 29);
247        assert_eq!(days_in_month(300_100, 2), 28);
248        assert_eq!(days_in_month(400_000, 1), 31);
249        assert_eq!(days_in_month(400_000, 4), 30);
250        assert_eq!(days_in_month(2024, 2), 29);
251        assert_eq!(days_in_month(2023, 2), 28);
252        assert_eq!(days_in_month(2023, 0), 0);
253        assert_eq!(days_in_month(2023, 13), 0);
254        // December at i32::MAX does not overflow and still reports 31 days.
255        assert_eq!(days_in_month(i32::MAX, 12), 31);
256        assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
257    }
258
259    #[test]
260    fn civil_conversions_exact_beyond_chrono_range() {
261        assert_eq!(days_from_civil(1970, 1, 1), 0);
262        assert_eq!(civil_from_days(0), (1970, 1, 1));
263        // Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
264        // the epoch; values round-trip through the proleptic Gregorian converters.
265        for (y, m, d, z) in [
266            (262_144i32, 1u32, 1u32, 95_026_602i64),
267            (300_000, 1, 1, 108_853_222),
268            (400_000, 2, 29, 145_377_531),
269            (-262_145, 3, 1, -96_465_964),
270            (-300_000, 6, 15, -110_292_112),
271        ] {
272            assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
273            assert_eq!(civil_from_days(z), (y, m, d), "{z}");
274        }
275        // The extremes must not overflow or panic (i128 intermediates).
276        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));
277    }
278
279    #[test]
280    fn serials_unchanged_through_cycle_conversion() {
281        // The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
282        // in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
283        let s = DateSystem::D1900;
284        assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
285        assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
286        assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
287        assert_eq!(days_in_month_in(s, 1900, 2), 29);
288        let s4 = DateSystem::D1904;
289        assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
290        assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
291        assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
292        assert_eq!(days_in_month_in(s4, 1900, 2), 28);
293    }
294
295    #[test]
296    fn serial_from_unix_time() {
297        assert_eq!(serial_from_unix_ms(0.0), 25569.0);
298        // 2026-10-10T12:00:00Z
299        assert_eq!(serial_from_unix_ms(1_791_633_600_000.0), 46305.5);
300        assert!(unix_now_ms().is_some_and(|ms| serial_from_unix_ms(ms) > 46305.0));
301    }
302
303    #[test]
304    fn known_serials() {
305        let s = DateSystem::D1900;
306        assert_eq!(serial_from_ymd(s, 1900, 1, 1), Some(1.0));
307        assert_eq!(serial_from_ymd(s, 1900, 2, 28), Some(59.0));
308        assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
309        assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0));
310        assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0));
311        assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
312        assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 31), Some(2957003.0));
313        assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 32), None);
314        assert!(datetime_from_serial(DateSystem::D1904, 2957003.5).is_some());
315        assert!(datetime_from_serial(DateSystem::D1904, 2957004.0).is_none());
316        assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1));
317        assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29));
318        assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1));
319        assert_eq!(serial_from_ymd(DateSystem::D1904, 1904, 1, 1), Some(0.0));
320        assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0));
321    }
322
323    #[test]
324    fn fictitious_1900_02_29() -> Result<(), std::io::Error> {
325        // Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
326        // through it.
327        let s = DateSystem::D1900;
328        assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0));
329        assert_eq!(serial_from_ymd(s, 1900, 3, 0), Some(60.0));
330        assert_eq!(serial_from_ymd(s, 1900, 2, 30), Some(61.0));
331        assert_eq!(serial_from_ymd(s, 1900, 1, 60), Some(60.0));
332        assert_eq!(serial_from_ymd(s, 1900, 1, 61), Some(61.0));
333        assert_eq!(serial_from_ymd(s, 1900, 1, 400), Some(400.0));
334        assert_eq!(serial_from_ymd(s, 1900, 4, -30), Some(61.0));
335        assert_eq!(serial_from_ymd(s, 1900, 4, -31), Some(60.0));
336        assert_eq!(serial_from_ymd(s, 1900, 3, -1), Some(59.0));
337        assert_eq!(serial_from_ymd(s, 1901, -10, 29), Some(60.0));
338        assert_eq!(serial_from_ymd(s, 1900, 1, 0), Some(0.0));
339        assert_eq!(serial_from_ymd(s, 1900, 1, -1), None);
340        assert_eq!(serial_from_ymd(s, 1900, 0, 1), None);
341        // Later years keep the real calendar.
342        assert_eq!(serial_from_ymd(s, 1901, 2, 29), serial_from_ymd(s, 1901, 3, 1));
343        assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0));
344        // Every serial up to 1900-12-31 comes back from its own year, month and day.
345        for serial in 0..=366 {
346            let d = datetime_from_serial(s, serial as f64).ok_or_else(|| std::io::Error::other("serial should convert"))?;
347            assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}");
348        }
349        assert_eq!(days_in_month_in(s, 1900, 2), 29);
350        assert_eq!(days_in_month_in(s, 1901, 2), 28);
351        assert_eq!(days_in_month_in(DateSystem::D1904, 1900, 2), 28);
352        // The 1904 system has no fictitious day: 1904 is a real leap year.
353        let s = DateSystem::D1904;
354        assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(59.0));
355        assert_eq!(serial_from_ymd(s, 1904, 3, 0), Some(59.0));
356        assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0));
357        assert_eq!(serial_from_ymd(s, 1904, 1, 0), None);
358        assert_eq!(serial_from_ymd(s, 1900, 2, 29), None);
359        Ok(())
360    }
361
362    #[test]
363    fn hostile_month_and_day() {
364        let s = DateSystem::D1900;
365        assert_eq!(serial_from_ymd(s, 2000, i64::MIN, 1), None);
366        assert_eq!(serial_from_ymd(s, 2000, i64::MAX, 1), None);
367        assert_eq!(serial_from_ymd(s, 2000, 1, i64::MAX), None);
368        assert_eq!(serial_from_ymd(s, 2000, 1, i64::MIN), None);
369        assert_eq!(serial_from_ymd(s, i64::MAX, 1, 1), None);
370    }
371
372    #[test]
373    fn roundtrip() -> Result<(), std::io::Error> {
374        let s = DateSystem::D1900;
375        for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] {
376            let d = datetime_from_serial(s, serial).ok_or_else(|| std::io::Error::other("serial should convert"))?;
377            assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial));
378        }
379        let d = datetime_from_serial(s, 60.0).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?;
380        assert_eq!((d.year, d.month, d.day), (1900, 2, 29));
381        let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?;
382        assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday
383        let d = datetime_from_serial(s, 1.0).ok_or_else(|| std::io::Error::other("first serial should convert"))?;
384        assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar
385        Ok(())
386    }
387}