From a61ddc684da865c24d84c845c1d52e142859a1e0 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 12 Nov 2026 11:53:08 -0700 Subject: [PATCH] fix(model): show timestamps on the user's clock A new page's date line and Insert Date/Time showed the stored UTC clock as if it were local time, with no zone, so in Denver at 10:18 a page read 17:18, and near midnight even the date was wrong (#133). Timestamps stay UTC on disk. `model::time` now formats a UTC timestamp on a local offset, which moves the day, month and year with it, and the desktop app sets that offset from the system once at start, while it is the only thread. Without an offset (tests, the command line, the browser build, or if the system cannot say) the clock is UTC, as before. Fixes #133 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 13 ++++++++++ Cargo.toml | 3 +++ apps/notecraft/Cargo.toml | 2 ++ apps/notecraft/src/main.rs | 11 ++++++++ crates/model/src/time.rs | 53 +++++++++++++++++++++++++++++++++++--- 5 files changed, 79 insertions(+), 3 deletions(-) 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] {