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

Replaces the two hand-rolled Howard Hinnant days-from-civil copies with chrono
as a workspace dependency:
- crates/collab/src/message.rs: utc_date (YYYY-MM-DD HH:MM UTC)
- crates/ui-egui/src/home_recents.rs: date (Mon D, YYYY, UTC; keeps the
  translated month names and trf formatting)
Output is unchanged. Neither function had a test, so each gains one pinning the
exact strings the old code produced (leap days included). chrono uses only std,
so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.

Follow-up to storytold/cadcraft#1785
This commit is contained in:
Dylan M. Taylor committed 2026-10-22 07:12:29 -04:00
1 parent 5fa586306a
commit 0e3876e6d6
8 files changed
+53 -23

No files matched your search

+1
View File
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
+1
View File
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
Generated
+11
View File
@@ -742,6 +742,15 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1499,6 +1508,7 @@ name = "figcraft-collab"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"chrono",
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
@@ -1744,6 +1754,7 @@ name = "figcraft-ui-egui"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"chrono",
"egui",
"egui_extras",
"figcraft-color",
+2
View File
@@ -45,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
imbl = { version = "7", features = ["serde"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
+1
View File
@@ -13,6 +13,7 @@ figcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
+18 -13
View File
@@ -699,19 +699,8 @@ pub enum ServerMsg {
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
/// messages).
pub fn utc_date(secs: u64) -> String {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian).
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 mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default();
t.format("%Y-%m-%d %H:%M UTC").to_string()
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
@@ -752,3 +741,19 @@ pub fn decode_json<T: DeserializeOwned>(text: &str, max_bytes: usize) -> Result<
}
serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
/// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days
/// code produced (leap days and all).
#[test]
fn utc_date_formats_seconds_since_1970() {
assert_eq!(utc_date(0), "1970-01-01 00:00 UTC");
assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC");
assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC");
assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC");
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
}
}
+1
View File
@@ -22,6 +22,7 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
base64 = { workspace = true }
image = { workspace = true }
egui_extras = { workspace = true }
+18 -10
View File
@@ -10,6 +10,7 @@
use std::collections::HashMap;
use std::path::Path;
use chrono::Datelike;
use egui::{Align2, Color32, CornerRadius, Id, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_engine::Prefs;
use figcraft_format::thumbnail::{FileKind, Preview};
@@ -202,16 +203,8 @@ const MONTHS: [&str; 12] = [
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026".
fn date(secs: u64) -> String {
// Days to a civil date (Howard Hinnant's days_from_civil, inverted).
let z = i64::try_from(secs / 86_400).unwrap_or(0) + 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 day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
let dt = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default().naive_utc();
let (year, month, day) = (i64::from(dt.year()), 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));
trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)])
}
@@ -965,3 +958,18 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
arrows(ui, recents, files, 0, 1);
action
}
#[cfg(test)]
mod tests {
use super::*;
/// `date` keeps the exact "Mon D, YYYY" (UTC) output the hand-rolled days-from-civil code
/// produced. With no catalog loaded the month and format strings pass through in English.
#[test]
fn date_formats_seconds_since_1970_in_utc() {
assert_eq!(date(0), "Jan 1, 1970");
assert_eq!(date(951_782_400), "Feb 29, 2000");
assert_eq!(date(1_709_164_800), "Feb 29, 2024");
assert_eq!(date(1_741_046_400), "Mar 4, 2025");
}
}