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

This repo already uses jiff (ui-egui, for the system time zone), so the
hand-rolled Howard Hinnant civil/days-from-civil copies move to jiff rather than
pulling in a second time crate. jiff is lifted to a workspace dependency:
- crates/core/src/time.rs: parse_rfc3339's days_from_civil and civil_from_unix
- xtask/src/local.rs: rfc3339 (feed timestamps)
ui-egui now takes jiff from the workspace. core keeps only civil-date math (no
time zone), so the wasm build is unaffected. Output and parsing semantics are
unchanged and pinned by the existing tests (RFC 3339 parsing, leap days, day
rollover, the feed's YYYY-MM-DDTHH:MM:SSZ). AGENTS.md now says time goes through
jiff.

Follow-up to storytold/cadcraft#1785
This commit is contained in:
Dylan M. Taylor committed 2026-10-21 07:59:58 -04:00
1 parent ba3b0345f0
commit caea05eff8
8 files changed
+35 -35

No files matched your search

+2
View File
@@ -113,6 +113,8 @@ 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.
## 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",
@@ -5875,6 +5876,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; 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 }
+16 -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,25 @@ 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). A time outside jiff's calendar range reads as the
/// epoch rather than panicking.
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 whole_days = jiff::SignedDuration::from_secs(secs.div_euclid(86_400).saturating_mul(86_400));
let date = epoch.checked_add(whole_days).unwrap_or(epoch);
(i64::from(date.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"];
+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 }
+5 -12
View File
@@ -493,20 +493,13 @@ 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).
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 whole_days = jiff::SignedDuration::from_secs(secs.div_euclid(86_400).saturating_mul(86_400));
let date = epoch.checked_add(whole_days).unwrap_or(epoch);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", date.year(), date.month(), date.day(), rem / 3600, rem % 3600 / 60, rem % 60)
}
// ---- versions -----------------------------------------------------------------------------------