diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9700a8e9..3dfa31d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,12 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ libgl1-mesa-dev libgtk-3-dev - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy, rustfmt # Only main saves caches; PRs restore main's, so PR caches don't evict main's. - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true @@ -75,10 +75,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: wasm32 save-if: ${{ github.ref == 'refs/heads/main' }} diff --git a/AGENTS.md b/AGENTS.md index 708da805..b2384311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/CLAUDE.md b/CLAUDE.md index 708da805..b2384311 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/Cargo.lock b/Cargo.lock index 459c8320..d2052d22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 6cc17546..b6fb2704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 beyond the 400-year range extension; 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"] } diff --git a/README.md b/README.md index 63bc739b..ebbf7fa0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@
+
+
+
diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml
index 89a79687..ed85201d 100644
--- a/crates/collab/Cargo.toml
+++ b/crates/collab/Cargo.toml
@@ -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 }
diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs
index d035042b..4fda776a 100644
--- a/crates/collab/src/message.rs
+++ b/crates/collab/src/message.rs
@@ -696,22 +696,38 @@ pub enum ServerMsg {
},
}
+/// (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) {
+ use chrono::Datelike;
+ 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
+ }
+}
+
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
-/// messages).
+/// 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 {
- 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")
+ // 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.
@@ -752,3 +768,25 @@ pub fn decode_json