diff --git a/Cargo.lock b/Cargo.lock index eb189983..d80d4a6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2756,12 +2756,14 @@ dependencies = [ "image", "log", "notecraft-engine", + "notecraft-model", "notecraft-setup", "notecraft-store", "notecraft-ui-egui", "raw-window-handle", "rfd", "serde_json", + "time", "web-time", "winresource", ] @@ -2957,6 +2959,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -4708,7 +4719,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", diff --git a/Cargo.toml b/Cargo.toml index ed4c09c3..e72fa1a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,9 @@ hayro = { version = "=0.5.0", default-features = false, features = ["embed-fonts # platform" on wasm32-unknown-unknown, which has no clock. This is std's types everywhere # else and Date.now() there, with no wasm-bindgen glue needed in notecraft-model itself. web-time = "1.1" +# The user's UTC offset, read once when the desktop app starts, so timestamps are shown on +# the user's clock. Already in the tree through zip; only the app names it. +time = { version = "0.3", default-features = false, features = ["std", "local-offset"] } # Mermaid 0.3.1 uses std::time::Instant in its layout metrics on the ordinary render path. # Those metrics are discarded by NoteCraft, but Instant::now() panics on wasm32-unknown-unknown. diff --git a/apps/notecraft/Cargo.toml b/apps/notecraft/Cargo.toml index 8a909836..7e231bdd 100644 --- a/apps/notecraft/Cargo.toml +++ b/apps/notecraft/Cargo.toml @@ -23,6 +23,8 @@ rfd = { workspace = true } log = { workspace = true } serde_json = { workspace = true } web-time = { workspace = true } +time = { workspace = true } +notecraft-model = { workspace = true } raw-window-handle = "0.6" [target.'cfg(windows)'.build-dependencies] diff --git a/apps/notecraft/src/main.rs b/apps/notecraft/src/main.rs index ad606239..9d7c4863 100644 --- a/apps/notecraft/src/main.rs +++ b/apps/notecraft/src/main.rs @@ -154,6 +154,13 @@ fn fail(message: &str) -> ! { } fn main() -> eframe::Result { + // First, while this is the only thread: on Linux and macOS the offset can only be read + // safely then. Timestamps are kept in UTC and shown on the user's clock (issue #133); if + // the offset cannot be read they are shown in UTC, as before. + let local_offset = time::UtcOffset::current_local_offset(); + if let Ok(offset) = local_offset { + notecraft_model::time::set_local_offset(offset.whole_seconds()); + } let args: Vec = std::env::args().skip(1).collect(); let settings_dir = Settings::default_location().path().parent().map(std::path::Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")); let telemetry_level = @@ -172,6 +179,10 @@ fn main() -> eframe::Result { std::env::current_exe().ok(), Settings::default_location().path() )); + match local_offset { + Ok(offset) => applog::info(&format!("local time is UTC{offset}")), + Err(error) => applog::info(&format!("local time offset unknown, timestamps are shown in UTC: {error}")), + } if args.iter().any(|a| a == "--version") { println!("notecraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); diff --git a/crates/model/src/time.rs b/crates/model/src/time.rs index 6959d919..c2f367e6 100644 --- a/crates/model/src/time.rs +++ b/crates/model/src/time.rs @@ -33,6 +33,21 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { const WEEKDAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"]; const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +/// How far the user's clock is ahead of UTC, in seconds. Timestamps are kept in UTC; this is +/// only for showing them (issue #133). Zero until the app sets it, so tests and the command +/// line read the same everywhere. +static LOCAL_OFFSET: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); + +/// Sets how far the user's clock is ahead of UTC, in seconds, for showing timestamps. +pub fn set_local_offset(seconds: i32) { + LOCAL_OFFSET.store(seconds, std::sync::atomic::Ordering::Relaxed); +} + +/// How far the user's clock is ahead of UTC, in seconds, as set with [`set_local_offset`]. +pub fn local_offset() -> i32 { + LOCAL_OFFSET.load(std::sync::atomic::Ordering::Relaxed) +} + /// The day alone, as a note writes it: "Saturday, 17 October 2026". pub fn date_from_iso(iso: &str) -> Option { let whole = long_from_iso(iso)?; @@ -45,8 +60,15 @@ pub fn clock_from_iso(iso: &str) -> Option { } /// A timestamp the way a note shows it, under the page title: "Saturday, 17 October 2026, -/// 18:29". English only for now, and the clock is UTC, like the stored timestamp. +/// 18:29". English only for now. A UTC timestamp (one ending in `Z`, as every one NoteCraft +/// writes does) is shown on the user's clock; one written with no zone is shown as written. pub fn long_from_iso(iso: &str) -> Option { + long_from_iso_at(iso, local_offset()) +} + +/// [`long_from_iso`] for a clock `offset` seconds ahead of UTC. +pub fn long_from_iso_at(iso: &str, offset: i32) -> Option { + let utc = iso.trim_end().ends_with('Z'); let (date, time) = iso.split_once('T')?; let mut parts = date.split('-'); let y: i64 = parts.next()?.parse().ok()?; @@ -59,11 +81,20 @@ pub fn long_from_iso(iso: &str) -> Option { let b = s.as_bytes(); b.get(2) == Some(&b':') && b.iter().enumerate().all(|(i, c)| i == 2 || c.is_ascii_digit()) })?; + let (hours, minutes): (i64, i64) = (hm[..2].parse().ok()?, hm[3..].parse().ok()?); + if hours > 23 || minutes > 59 { + return None; + } + // Moved onto the user's clock, which can also move the day, the month or the year. + let shift = if utc { i64::from(offset) } else { 0 }; + let at = days_from_civil(y, m, d) * 86_400 + hours * 3_600 + minutes * 60 + shift; + let days = at.div_euclid(86_400); + let (y, m, d) = civil_from_days(days); + let rem = at.rem_euclid(86_400); // 1970-01-01 was a Thursday, so the weekday follows from the day count. - let days = days_from_civil(y, m, d); let weekday = WEEKDAYS.get(days.rem_euclid(7) as usize)?; let month = MONTHS.get((m - 1) as usize)?; - Some(format!("{weekday}, {d} {month} {y}, {hm}")) + Some(format!("{weekday}, {d} {month} {y}, {:02}:{:02}", rem / 3_600, rem % 3_600 / 60)) } /// The inverse of [`civil_from_days`]. @@ -115,6 +146,22 @@ mod tests { } } + /// Issue #133: a UTC timestamp is shown on the user's clock, which can move the day, the + /// month and the year, and one written with no zone is shown as written. + #[test] + fn a_timestamp_is_shown_on_the_users_clock() { + let iso = "2026-11-12T17:18:00Z"; + assert_eq!(long_from_iso_at(iso, 0).as_deref(), Some("Thursday, 12 November 2026, 17:18")); + assert_eq!(long_from_iso_at(iso, -7 * 3_600).as_deref(), Some("Thursday, 12 November 2026, 10:18"), "Denver in November"); + assert_eq!(long_from_iso_at(iso, 5 * 3_600 + 30 * 60).as_deref(), Some("Thursday, 12 November 2026, 22:48"), "a half hour zone"); + assert_eq!(long_from_iso_at("2026-11-12T02:00:00Z", -7 * 3_600).as_deref(), Some("Wednesday, 11 November 2026, 19:00"), "the day before"); + assert_eq!(long_from_iso_at("2026-12-31T23:30:00Z", 3_600).as_deref(), Some("Friday, 1 January 2027, 00:30"), "the next year"); + assert_eq!(long_from_iso_at("2024-03-01T00:30:00Z", -3_600).as_deref(), Some("Thursday, 29 February 2024, 23:30"), "a leap day"); + assert_eq!(long_from_iso_at("2026-11-12T17:18:00", -7 * 3_600).as_deref(), Some("Thursday, 12 November 2026, 17:18"), "no zone: as written"); + assert_eq!(long_from_iso_at("2026-11-12T25:00:00Z", 0), None, "an hour that is not one"); + assert_eq!(long_from_iso("2026-11-12T17:18:00Z"), long_from_iso_at("2026-11-12T17:18:00Z", local_offset())); + } + #[test] fn the_day_count_round_trips() { for secs in [0_i64, 1_000_000, 1_700_000_000, -86_400 * 365] {