Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/notecraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions apps/notecraft/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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 =
Expand All @@ -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(());
Expand Down
53 changes: 50 additions & 3 deletions crates/model/src/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let whole = long_from_iso(iso)?;
Expand All @@ -45,8 +60,15 @@ pub fn clock_from_iso(iso: &str) -> Option<String> {
}

/// 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<String> {
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<String> {
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()?;
Expand All @@ -59,11 +81,20 @@ pub fn long_from_iso(iso: &str) -> Option<String> {
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`].
Expand Down Expand Up @@ -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] {
Expand Down
Loading