Use chrono instead of hand-rolled UTC conversion (#2121)

* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies and the
is_leap / days_in_month calendar helpers with chrono as a workspace dependency
(default-features = false, std only, so wasm is unaffected). The spreadsheet
serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial
60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday
numbering and the 1900/1904 systems keep their explicit special cases in
serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs
the plain proleptic-Gregorian day-number <-> date conversion and the
leap-year / month-length helpers those build on; a small fallback keeps the old
answer for dates outside chrono's NaiveDate range. The duplicate local is_leap
in the financial functions now uses the core one. Output is byte-identical and
pinned by the existing date, parse, financial and logging tests. AGENTS.md now
says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on out-of-range clocks or years; keep calendar results outside chrono's range

- logging::timestamp: chrono's From<SystemTime> unwraps, so a clock past year
  262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
  and read such a clock as the epoch, like the old saturating code. Years past
  9999 are written with {:04} (not %Y, which prefixes a '+').
- date::is_leap: restore the Gregorian leap-year rule for years outside chrono's
  NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic.
- date::days_in_month: compute the December boundary with checked_add so
  days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths
  through days_from_civil's fallback so out-of-range years keep their real
  lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0.
- civil_from_days: add missing docstring.
- AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies

The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback`
copies for dates outside chrono's ±262143-year range, plus a hand-rolled
Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`.
AGENTS.md carved a "narrow fallback" exception for them, which left calendar
arithmetic outside chrono after all.

Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce
the year with `rem_euclid(400)` (leap years and month lengths repeat every 400
years), which also removes the December `i32::MAX` overflow special-case.

chrono is now the only calendar logic, and every output is unchanged: verified
the cycle result equals the old formula over wide ranges and at the i32/i64
extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 =
fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/
`datetime_from_serial` only ever hand the converters in-range years, so their
serials are byte-for-byte the pre-chrono ones (pinned by a new test).

New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both
sides of the epoch, round-tripping, with values from the old formula; the
existing leap/month-length-beyond-range test still passes unchanged. Removed the
AGENTS.md "narrow fallback" exception.

Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check;
clippy --all-targets -D warnings.

* time: preserve out-of-range years in the log timestamp instead of the epoch

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).

apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
  u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
  10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
This commit is contained in:
Dylan M. Taylor
2026-10-23 11:48:02 -04:00
committed by GitHub
parent 168acc5889
commit bdf8b8cc75
7 changed files with 163 additions and 43 deletions
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
+111 -24
View File
@@ -28,15 +28,22 @@ pub struct DateTime {
pub weekday: u32,
}
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
/// 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 y = if m <= 2 { y as i64 - 1 } else { y as i64 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = m as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
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.
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
}
}
/// 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) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
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 {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
// 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 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(y) => 29,
2 => 28,
// 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,
}
}
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
mod tests {
use super::*;
#[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);