From 4d5d4b2e9a426682fe116b1c7251be21b5a151e2 Mon Sep 17 00:00:00 2001 From: Triyambak Date: Tue, 1 Sep 2026 14:34:29 +0530 Subject: [PATCH] feat(pick): show message times on the viewer's clock, not UTC The picker sliced HH:MM straight out of the RFC 3339 stamp, so a transcript written in UTC was displayed in UTC with no zone marker. At +05:30 that reads five and a half hours in the past, and since the row shows a clock face and no date, a message from last month looks like one from this evening. It cost me real time twice before I noticed. Times now move to the machine's offset. Only a `Z` stamp is moved; one that already carries an offset is local to whoever wrote it and is shown as written. No date arithmetic is involved, because crossing midnight changes the date, not the clock face. std has no local-time API and this crate deliberately carries no date dependency, so the offset comes from `date +%z`, the same shell-out last::locate already uses, resolved once. Non-unix and anything unparseable stay on UTC, which is the behaviour they have today. The offset is held on App so the drawing test can pin it to 0 and the picker renders identically on any machine. --- crates/plannotator-tui/src/app/mod.rs | 4 ++ crates/plannotator-tui/src/app/pick.rs | 84 ++++++++++++++++++++++--- crates/plannotator-tui/src/app/tests.rs | 1 + 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index e380f3e..10eb1be 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -136,6 +136,9 @@ pub(crate) struct App { /// `last`: the agent's recent messages, newest first, and the picker's cursor. candidates: Vec, pick_cursor: usize, + /// Minutes east of UTC used to draw message times. Pinned in tests so the picker + /// renders the same on any machine. + clock_offset: i32, message_host: String, message_transcript: String, compose: Compose, @@ -191,6 +194,7 @@ impl App { mode: Mode::Browse, candidates: Vec::new(), pick_cursor: 0, + clock_offset: pick::local_offset_minutes(), message_host: String::new(), message_transcript: String::new(), compose: Compose::default(), diff --git a/crates/plannotator-tui/src/app/pick.rs b/crates/plannotator-tui/src/app/pick.rs index 7846096..6ea80b9 100644 --- a/crates/plannotator-tui/src/app/pick.rs +++ b/crates/plannotator-tui/src/app/pick.rs @@ -1,6 +1,9 @@ //! The message picker: which of the agent's recent messages to review. Newest first, the //! newest already open behind it. +use std::process::Command; +use std::sync::OnceLock; + use anyhow::Result; use plannotator_tui_hosts::Message; use ratatui::Frame; @@ -110,7 +113,8 @@ impl App { .map(|(index, message)| { let row = Rect { x: inner.x, y: inner.y + index as u16, width: inner.width, height: 1 }; pick_rows.push((row, index)); - let text = fit(&pick_label(message), usize::from(inner.width).saturating_sub(1)); + let text = + fit(&pick_label(message, self.clock_offset), usize::from(inner.width).saturating_sub(1)); let style = if index == self.pick_cursor { Style::new().reversed() } else { Style::new() }; Line::from(Span::styled(format!(" {text}"), style)) }) @@ -120,17 +124,58 @@ impl App { } } -/// `HH:MM first line of the message`. -fn pick_label(message: &Message) -> String { - let time = message.at.as_deref().and_then(clock).unwrap_or_else(|| " ".to_owned()); +/// `HH:MM first line of the message`, the clock in the viewer's timezone. +fn pick_label(message: &Message, offset_minutes: i32) -> String { + let time = + message.at.as_deref().and_then(|at| clock(at, offset_minutes)).unwrap_or_else(|| " ".to_owned()); let first = message.text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim(); format!("{time} {first}") } -/// `HH:MM` out of an RFC 3339 timestamp; anything else is left blank. -fn clock(at: &str) -> Option { +/// `HH:MM` out of an RFC 3339 timestamp, moved to `offset_minutes` east of UTC. +/// +/// Only a `Z` stamp is moved. One that already carries an offset is local to whoever +/// wrote it and is shown as written. No date arithmetic is needed: crossing midnight +/// changes the date, not the clock face. +fn clock(at: &str, offset_minutes: i32) -> Option { let time = at.get(11..16)?; - (time.len() == 5 && time.as_bytes().get(2) == Some(&b':')).then(|| time.to_owned()) + let (hours, minutes) = time.split_once(':')?; + let hours: i32 = hours.parse().ok()?; + let minutes: i32 = minutes.parse().ok()?; + if !at.ends_with(['Z', 'z']) { + return Some(time.to_owned()); + } + let total = (hours * 60 + minutes + offset_minutes).rem_euclid(24 * 60); + Some(format!("{:02}:{:02}", total / 60, total % 60)) +} + +/// Minutes east of UTC for this machine, resolved once. +/// +/// `std` has no local-time API and this crate carries no date dependency, so the offset +/// comes from `date +%z` - the same shell-out `last::locate` already uses. Anything +/// unexpected leaves the clock in UTC, which is what it showed before. +pub(super) fn local_offset_minutes() -> i32 { + static OFFSET: OnceLock = OnceLock::new(); + *OFFSET.get_or_init(|| { + if !cfg!(unix) { + return 0; + } + let Ok(output) = Command::new("date").arg("+%z").output() else { return 0 }; + let Ok(text) = String::from_utf8(output.stdout) else { return 0 }; + parse_utc_offset(text.trim()).unwrap_or(0) + }) +} + +/// `+0530` or `-0800` as minutes east of UTC. +fn parse_utc_offset(zone: &str) -> Option { + let sign = match zone.as_bytes().first()? { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + let hours: i32 = zone.get(1..3)?.parse().ok()?; + let minutes: i32 = zone.get(3..5)?.parse().ok()?; + Some(sign * (hours * 60 + minutes)) } fn fit(text: &str, width: usize) -> String { @@ -147,3 +192,28 @@ fn fit(text: &str, width: usize) -> String { out.push('…'); out } + +#[cfg(test)] +mod tests { + use super::{clock, parse_utc_offset}; + + #[test] + fn a_utc_stamp_is_shown_on_the_local_clock() { + assert_eq!(clock("2026-08-31T19:53:52.563Z", 330).as_deref(), Some("01:23")); + assert_eq!(clock("2026-08-31T19:53:52.563Z", 0).as_deref(), Some("19:53")); + assert_eq!(clock("2026-08-31T02:10:00.000Z", -480).as_deref(), Some("18:10")); + } + + #[test] + fn a_stamp_that_already_carries_an_offset_is_shown_as_written() { + assert_eq!(clock("2026-08-31T19:53:52+05:30", 330).as_deref(), Some("19:53")); + } + + #[test] + fn a_zone_string_reads_as_minutes_east_of_utc() { + assert_eq!(parse_utc_offset("+0530"), Some(330)); + assert_eq!(parse_utc_offset("-0800"), Some(-480)); + assert_eq!(parse_utc_offset("+0000"), Some(0)); + assert_eq!(parse_utc_offset("nonsense"), None); + } +} diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 3cca245..9288a8d 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -109,6 +109,7 @@ fn candidates() -> Vec { fn the_picker_lists_newest_first_and_opens_the_chosen_message() { let mut app = App::open_message("claude", "/tmp/transcript.jsonl", candidates(), 60, Box::new(Discard)) .expect("opens"); + app.clock_offset = 0; assert_eq!(app.mode, Mode::Pick, "more than one candidate asks which"); let rows = draw(&mut app); let listed: Vec<&str> = rows.iter().map(String::as_str).filter(|r| r.contains("12:")).collect();