collab/ui-egui: keep old UTC output past chrono's year range

`utc_date` (collab) and `date` (ui-egui) took `u64` seconds, which the old
hand-rolled civil-from-days code converted for the whole `u64` range. The
chrono rewrite used `from_timestamp(..).unwrap_or_default()`, so any instant
past chrono's own year limit (262143) silently became the epoch — changing
existing output. `u64::MAX` seconds, for example, used to read
`584554051223-11-09 07:00 UTC` / `Nov 9, 584554051223`, not 1970.

Fix: add a `utc_parts(days, sod, nanos)` helper per crate that moves the
instant into chrono's range by whole 400-year Gregorian cycles (146_097 days),
lets chrono do the month/day/time, and restores the real year afterwards.
chrono stays the only calendar logic; the cycle count just shifts the year, and
every `u64` day count fits in `i64` so the conversion is always exact (no epoch
fallback). Split seconds with `u64` arithmetic first to avoid the `i64` seconds
overflow near `u64::MAX`, matching the old code.

Tests now pin the old output instead of the epoch:
- collab utc_date(u64::MAX) == "584554051223-11-09 07:00 UTC"
  (and utc_date(253_402_300_800) == "10000-01-01 00:00 UTC", already pinned)
- ui-egui date(253_402_300_800) == "Jan 1, 10000"
  date(u64::MAX) == "Nov 9, 584554051223"
This commit is contained in:
Dylan M. Taylor
2026-10-22 07:12:29 -04:00
parent 630e0dd403
commit e3169e5a61
2 changed files with 69 additions and 11 deletions
+35 -8
View File
@@ -696,13 +696,38 @@ pub enum ServerMsg {
}, },
} }
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these /// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
/// messages). A time past the last date chrono can represent (year 262143) reads as the epoch. /// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400
pub fn utc_date(secs: u64) -> String { /// years (146_097 days), so month, day and time of day come from chrono and only the year is
/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar
/// math: chrono still does the date, the cycle count just shifts the year.
fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike; use chrono::Datelike;
let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default(); let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not). // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
format!("{:04}-{}", t.year(), t.format("%m-%d %H:%M UTC")) let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into
/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the
/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`).
pub fn utc_date(secs: u64) -> String {
// The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the
// seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting
// with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`.
let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
let (y, t) = utc_parts(days, sod, 0);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not).
format!("{y:04}-{}", t.format("%m-%d %H:%M UTC"))
} }
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard. /// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
@@ -759,7 +784,9 @@ mod tests {
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC"); assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did. // A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC"); assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC");
// Past chrono's range reads as the epoch instead of panicking. // Past chrono's own year range (262143) the instant is moved into range by whole 400-year
assert_eq!(utc_date(u64::MAX), "1970-01-01 00:00 UTC"); // cycles and the real year restored, so it keeps the old hand-rolled output instead of
// falling back to the epoch.
assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC");
} }
} }
+34 -3
View File
@@ -201,10 +201,35 @@ const MONTHS: [&str; 12] = [
tr_noop("Dec"), tr_noop("Dec"),
]; ];
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". /// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400
/// years (146_097 days), so month, day and time of day come from chrono and only the year is
/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar
/// math: chrono still does the date, the cycle count just shifts the year.
fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". Years past chrono's own range (262143) keep
/// converting: the instant is moved into range by whole 400-year cycles and the real year restored,
/// so every `u64` reads as the date the old hand-rolled conversion produced.
fn date(secs: u64) -> String { fn date(secs: u64) -> String {
let dt = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default().naive_utc(); // The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), so the
let (year, month, day) = (i64::from(dt.year()), i64::from(dt.month()), i64::from(dt.day())); // conversion is always exact (no epoch fallback). Splitting with `u64` arithmetic (as the old
// code did) avoids the `i64` seconds overflow near `u64::MAX`.
let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
let (year, dt) = utc_parts(days, sod, 0);
let (month, day) = (i64::from(dt.month()), i64::from(dt.day()));
let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m)); let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m));
trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)]) trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)])
} }
@@ -971,5 +996,11 @@ mod tests {
assert_eq!(date(951_782_400), "Feb 29, 2000"); assert_eq!(date(951_782_400), "Feb 29, 2000");
assert_eq!(date(1_709_164_800), "Feb 29, 2024"); assert_eq!(date(1_709_164_800), "Feb 29, 2024");
assert_eq!(date(1_741_046_400), "Mar 4, 2025"); assert_eq!(date(1_741_046_400), "Mar 4, 2025");
// A year past chrono's own `NaiveDate` range (262143): the instant is moved into range by
// whole 400-year cycles and the real year restored.
assert_eq!(date(253_402_300_800), "Jan 1, 10000");
// Past chrono's year range, `u64::MAX` keeps the old hand-rolled output instead of falling
// back to the epoch.
assert_eq!(date(u64::MAX), "Nov 9, 584554051223");
} }
} }