diff --git a/AGENTS.md b/AGENTS.md index e2f12d9d..25d12b49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,7 @@ Standard: craftrules `standards/never-crash.md`. CLI, control channel and MCP do exactly the same thing. - **The UI is thin**: panels read engine state and act through commands. Colours come from theme tokens. **Never use Tauri**; egui only. +- **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). - **Never break wasm** (`cargo xtask wasm`). ## 5. Quality gates (before every commit) diff --git a/Cargo.lock b/Cargo.lock index c4381e3c..9885eb33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,6 +895,7 @@ name = "deckcraft" version = "0.10.0" dependencies = [ "arboard", + "chrono", "cpal", "deckcraft-engine", "deckcraft-media", @@ -956,6 +957,7 @@ dependencies = [ name = "deckcraft-engine" version = "0.10.0" dependencies = [ + "chrono", "deckcraft-anim", "deckcraft-color", "deckcraft-fonts", diff --git a/Cargo.toml b/Cargo.toml index 8a942b9e..1b52575f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,9 @@ rayon = "1" unicode-bidi = "0.3" unicode-segmentation = "1" unicode-linebreak = "0.1" -chrono = { version = "0.4", default-features = false, features = ["clock"] } +# Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested +# per-crate for targets that read the local clock (see crates/text). +chrono = { version = "0.4", default-features = false, features = ["std"] } # text / rendering / formats skrifa = "0.47" diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml index fb3b489c..f3798529 100644 --- a/apps/deckcraft/Cargo.toml +++ b/apps/deckcraft/Cargo.toml @@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for deckcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/deckcraft/src/logging.rs b/apps/deckcraft/src/logging.rs index a0ca5048..f2cbae0d 100644 --- a/apps/deckcraft/src/logging.rs +++ b/apps/deckcraft/src/logging.rs @@ -95,29 +95,35 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock +/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved) +/// instead of panicking. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd -/// clock can't overflow). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 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 = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian 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 every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + 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 = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 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(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -389,6 +395,17 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 778664a7..cb1f4c29 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,6 +25,7 @@ i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } +chrono = { workspace = true } [dev-dependencies] deckcraft-anim = { workspace = true } diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 03468f37..012e33ed 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -943,18 +943,21 @@ pub fn date_field_text(kind: &str) -> String { DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() } -/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm). +/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. pub fn civil(z: i64) -> (i64, u32, u32) { - let z = z + 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 y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) + use chrono::Datelike; + // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 + // years (146_097 days). chrono converts the day within its cycle and whole cycles go back + // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and + // 0..146_097 days from it always fits. + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + 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.month(), d.day()) + } + None => (1970, 1, 1), + } } fn master_view(s: &mut Session, p: &Value) -> Result { @@ -1495,6 +1498,11 @@ mod tests { fn civil_dates() { assert_eq!(super::civil(0), (1970, 1, 1)); assert_eq!(super::civil(20_734), (2026, 10, 8)); + // Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes. + assert_eq!(super::civil(95_026_602), (262_144, 1, 1)); + assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1)); + assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27)); + assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7)); } #[test] diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 79302877..c7dfa024 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -16,7 +16,10 @@ deckcraft-fonts = { workspace = true } unicode-linebreak = { workspace = true } unicode-segmentation = { workspace = true } unicode-bidi = { workspace = true } +# Unconditional: `is_valid`/`weekday` use chrono on every target, including wasm32. +# Only the wasm-safe `std` baseline here (inherited from the workspace entry). +chrono = { workspace = true } # The local clock for date fields (no clock on wasm: fields keep their saved text). [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -chrono = { workspace = true } +chrono = { workspace = true, features = ["clock"] } diff --git a/crates/text/src/datetime.rs b/crates/text/src/datetime.rs index 8844f299..56282675 100644 --- a/crates/text/src/datetime.rs +++ b/crates/text/src/datetime.rs @@ -42,27 +42,17 @@ impl DateTime { /// A real calendar date and time of day. fn is_valid(&self) -> bool { - let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0); - let days = match self.month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if leap => 29, - 2 => 28, - _ => return false, - }; - (1..=9999).contains(&self.year) && (1..=days).contains(&self.day) && self.hour < 24 && self.minute < 60 && self.second < 61 + let date_ok = (1..=9999).contains(&self.year) && chrono::NaiveDate::from_ymd_opt(self.year, self.month, self.day).is_some(); + date_ok && self.hour < 24 && self.minute < 60 && self.second < 61 } - /// Day of the week, 0 = Sunday (days since 1970-01-01, Howard Hinnant's algorithm). + /// Day of the week, 0 = Sunday. fn weekday(&self) -> usize { - let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day)); - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; - (days + 4).rem_euclid(7) as usize + match chrono::NaiveDate::from_ymd_opt(self.year, self.month, self.day) { + // `chrono::Weekday::num_days_from_sunday` is 0 = Sunday. + Some(d) => chrono::Datelike::weekday(&d).num_days_from_sunday() as usize, + None => 0, + } } }