Merge pull request #726 from dylanmtaylor/time/chrono-1785

Use jiff instead of hand-rolled UTC conversion
This commit is contained in:
Dylan M. Taylor
2026-10-22 12:43:55 -04:00
committed by GitHub
8 changed files with 72 additions and 35 deletions
+6
View File
@@ -113,6 +113,12 @@ only; English where there is none): never bundle a CJK font, they are 10 MB and
GitHub release JSON fixtures (small, committed under `crates/core/tests/fixtures/`).
5. When fetching anything, use a generic `ArtCraft-Launcher/<version>` User-Agent and never put
a person's name, email or other personal details in requests.
6. **Time goes through `jiff`** (workspace dependency). Never hand-roll UTC/calendar conversion
(days-to-date algorithms, leap-year math); a module owns only its output format string. The one
exception is range extension: past the years jiff can represent, shift the date by whole 400-year
Gregorian cycles (146,097 days, after which the calendar repeats), let jiff compute the month and
day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is
plain arithmetic).
## 3. Before you finish a task
Generated
+4
View File
@@ -303,6 +303,7 @@ dependencies = [
name = "artcraft-launcher-core"
version = "0.6.1"
dependencies = [
"jiff",
"semver",
"serde",
"serde_json",
@@ -5882,6 +5883,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.6.1"
dependencies = [
"jiff",
]
[[package]]
name = "yoke"
+2
View File
@@ -28,6 +28,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
semver = { version = "1", features = ["serde"] }
log = "0.4"
# All date/time conversion goes through jiff (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
jiff = "0.2"
[profile.dev.package."*"]
opt-level = 2
+2
View File
@@ -14,3 +14,5 @@ workspace = true
serde = { workspace = true }
serde_json = { workspace = true }
semver = { workspace = true }
# Calendar conversion (jiff); see AGENTS.md. Only civil-date math is used, no time zone.
jiff = { workspace = true }
+34 -22
View File
@@ -1,4 +1,5 @@
//! Just enough calendar to read GitHub's timestamps and show them, without a date crate.
//! Just enough calendar to read GitHub's timestamps and show them (the conversion goes through
//! jiff; this module owns only the parsing and the output format strings).
//!
//! # Internationalisation
//! [`format_ago`] and [`format_date`] are English-only fallbacks. Translatable UI must use
@@ -64,32 +65,35 @@ pub fn parse_rfc3339(text: &str) -> Option<i64> {
}
_ => return None,
};
Some(days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s - offset)
Some(days_from_civil(y, mo, d)? * 86_400 + h * 3600 + mi * 60 + s - offset)
}
/// Days since 1970-01-01 (Howard Hinnant's algorithm).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
/// Days since 1970-01-01 (through jiff's civil calendar). A day past the month's length rolls over
/// the way the old Hinnant arithmetic did (e.g. `2026-02-30` → `2026-03-02`). `None` for a year
/// outside jiff's range.
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
let first = jiff::civil::Date::new(i16::try_from(y).ok()?, i8::try_from(m).ok()?, 1).ok()?;
let date = first.checked_add(jiff::SignedDuration::from_hours(d.checked_sub(1)?.checked_mul(24)?)).ok()?;
Some(date.duration_since(jiff::civil::Date::constant(1970, 1, 1)).as_secs() / 86_400)
}
/// (year, month, day) for a Unix time.
/// (year, month, day) for a Unix time (UTC). jiff's civil calendar only spans years -9999..=9999,
/// far narrower than the whole `i64` the old hand-rolled Hinnant code converted, so the instant is
/// first moved into range by whole 400-year Gregorian cycles (the calendar repeats every 400 years
/// = 146_097 days) and the real year restored afterwards. jiff still does the month/day; the cycle
/// count only shifts the year, keeping every `i64` exact (e.g. `i64::MAX` → year 292277026596,
/// `i64::MIN` → year -292277022657), with no epoch fallback for a value the old code converted.
pub fn civil_from_unix(secs: i64) -> (i64, u32, u32) {
let z = secs.div_euclid(86_400) + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
(y, u32::try_from(m).unwrap_or(1), u32::try_from(d).unwrap_or(1))
let epoch = jiff::civil::Date::constant(1970, 1, 1);
let days = secs.div_euclid(86_400);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest` is in `0..146_097` (under one 400-year cycle), so `epoch + rest days` always lands in
// 1970..=2369 — comfortably inside jiff's range — and `rest * 86_400` never overflows.
let date = epoch.checked_add(jiff::SignedDuration::from_secs(rest * 86_400)).unwrap_or(epoch);
// Restore the real year through `i128` so `cycles * 400` can't overflow at the `i64` extremes.
let y = i128::from(date.year()) + i128::from(cycles) * 400;
let year = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
(year, u32::try_from(date.month()).unwrap_or(1), u32::try_from(date.day()).unwrap_or(1))
}
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -161,6 +165,14 @@ mod tests {
fn shows_dates_people_read() {
assert_eq!(format_date(1_791_471_170), "Oct 8, 2026");
assert_eq!(civil_from_unix(951_782_400), (2000, 2, 29));
// Year 10000 is past jiff's own calendar range, but the 400-year cycle reduction keeps the
// old conversion (jiff spans only -9999..=9999).
assert_eq!(civil_from_unix(253_402_300_800), (10000, 1, 1));
// The `i64` extremes stay the dates the old Hinnant code produced, not the epoch.
assert_eq!(civil_from_unix(i64::MAX), (292_277_026_596, 12, 4));
assert_eq!(civil_from_unix(i64::MIN), (-292_277_022_657, 1, 27));
assert_eq!(format_date(i64::MAX), "Dec 4, 292277026596");
assert_eq!(format_date(i64::MIN), "Jan 27, -292277022657");
let now = 1_791_471_170;
assert_eq!(format_ago(now - 5, now), "just now");
assert_eq!(format_ago(now - 600, now), "10 min ago");
+1 -1
View File
@@ -24,7 +24,7 @@ sys-locale = "0.3"
# Finding the Simplified Chinese face in a system font collection; egui already uses it.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
# The system time zone, so dates read as this computer's calendar; already in the desktop build.
jiff = "0.2"
jiff = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The browser's time zone, for the same dates.
+3
View File
@@ -7,3 +7,6 @@ publish = false
[lints]
workspace = true
[dependencies]
jiff = { workspace = true }
+20 -12
View File
@@ -493,20 +493,23 @@ fn unix_now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0))
}
/// `2026-10-10T12:00:00Z` (civil-from-days, Howard Hinnant's algorithm).
/// `2026-10-10T12:00:00Z` (through jiff's civil calendar). jiff spans only years -9999..=9999, far
/// narrower than the whole `i64` the old hand-rolled Hinnant code converted, so the instant is
/// moved into range by whole 400-year Gregorian cycles (146_097 days) and the real year restored
/// afterwards. jiff still does the month/day; the cycle count only shifts the year, keeping every
/// `i64` exact (e.g. `i64::MAX` → year 292277026596) with no epoch fallback.
fn rfc3339(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
let epoch = jiff::civil::Date::constant(1970, 1, 1);
let days = secs.div_euclid(86_400);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest` is in `0..146_097` (under one 400-year cycle), so `epoch + rest days` lands in
// 1970..=2369 (inside jiff's range) and `rest * 86_400` never overflows.
let date = epoch.checked_add(jiff::SignedDuration::from_secs(rest * 86_400)).unwrap_or(epoch);
// Restore the real year through `i128` so `cycles * 400` can't overflow at the `i64` extremes.
let y = i128::from(date.year()) + i128::from(cycles) * 400;
let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", date.month(), date.day(), rem / 3600, rem % 3600 / 60, rem % 60)
}
// ---- versions -----------------------------------------------------------------------------------
@@ -692,6 +695,11 @@ mod tests {
fn dates_and_versions() {
assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339(1_791_500_000), "2026-10-08T22:53:20Z");
// Year 10000 and the `i64` extremes are past jiff's own calendar range (-9999..=9999), but
// the 400-year cycle reduction keeps the dates the old Hinnant code produced, not the epoch.
assert_eq!(rfc3339(253_402_300_800), "10000-01-01T00:00:00Z");
assert_eq!(rfc3339(i64::MAX), "292277026596-12-04T15:30:07Z");
assert_eq!(rfc3339(i64::MIN), "-292277022657-01-27T08:29:52Z");
assert_eq!(compare_versions("0.10.0", "0.9.0"), std::cmp::Ordering::Greater);
assert_eq!(compare_versions("1.0.0", "1.0.0-rc.1"), std::cmp::Ordering::Greater);
assert!(parse_version("1.2").is_none() && parse_version(".building-1.2.3").is_none());