From ef0c212aeb319dea8a5c939842610e30113fcf9d Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 02:38:48 +0530 Subject: [PATCH 1/2] fix(calendar): use local timezone for agenda day boundaries Previously, --today and --tomorrow computed day boundaries using UTC epoch arithmetic, so after local midnight \!= UTC midnight the wrong day's events were returned. Now uses chrono::Local to derive midnight in the user's timezone. Also fixes --today which had no explicit branch and fell through to the generic "N days from now" path. --- .changeset/fix-agenda-local-timezone.md | 5 ++ src/helpers/calendar.rs | 84 ++++++++++++++++++------- 2 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-agenda-local-timezone.md diff --git a/.changeset/fix-agenda-local-timezone.md b/.changeset/fix-agenda-local-timezone.md new file mode 100644 index 00000000..413193f5 --- /dev/null +++ b/.changeset/fix-agenda-local-timezone.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(calendar): use local timezone for agenda day boundaries instead of UTC diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index e300592f..2dfb20ea 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -200,36 +200,47 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { .map(|s| crate::formatter::OutputFormat::from_str(s)) .unwrap_or(crate::formatter::OutputFormat::Table); - // Determine time range - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let days: u64 = if matches.get_flag("tomorrow") { - // Start from tomorrow, 1 day + // Determine time range using the local timezone so that --today and + // --tomorrow align with the user's wall-clock day, not UTC. + use chrono::{Local, NaiveTime, TimeZone}; + + let local_now = Local::now(); + let today_start = local_now + .date_naive() + .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); + let today_start_local = Local + .from_local_datetime(&today_start) + .single() + .unwrap_or(local_now); + + let days: i64 = if matches.get_flag("tomorrow") { 1 } else if matches.get_flag("week") { 7 } else { matches .get_one::("days") - .and_then(|s| s.parse::().ok()) + .and_then(|s| s.parse::().ok()) .unwrap_or(1) }; - let (time_min_epoch, time_max_epoch) = if matches.get_flag("tomorrow") { - // Tomorrow: start of tomorrow to end of tomorrow - let day_seconds = 86400; - let tomorrow_start = (now / day_seconds + 1) * day_seconds; - (tomorrow_start, tomorrow_start + day_seconds) + let (time_min_dt, time_max_dt) = if matches.get_flag("today") { + // Today: local midnight to local midnight+1 + let end = today_start_local + chrono::Duration::days(1); + (today_start_local, end) + } else if matches.get_flag("tomorrow") { + // Tomorrow: local midnight+1 to local midnight+2 + let start = today_start_local + chrono::Duration::days(1); + let end = today_start_local + chrono::Duration::days(2); + (start, end) } else { - // Start from now - (now, now + days * 86400) + // From now, N days ahead + let end = local_now + chrono::Duration::days(days); + (local_now, end) }; - let time_min = epoch_to_rfc3339(time_min_epoch); - let time_max = epoch_to_rfc3339(time_max_epoch); + let time_min = time_min_dt.to_rfc3339(); + let time_max = time_max_dt.to_rfc3339(); let client = crate::client::build_client()?; let calendar_filter = matches.get_one::("calendar"); @@ -395,11 +406,6 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { Ok(()) } -fn epoch_to_rfc3339(epoch: u64) -> String { - use chrono::{TimeZone, Utc}; - Utc.timestamp_opt(epoch as i64, 0).unwrap().to_rfc3339() -} - fn build_insert_request( matches: &ArgMatches, doc: &crate::discovery::RestDescription, @@ -536,4 +542,36 @@ mod tests { assert!(body.contains("a@b.com")); assert!(body.contains("c@d.com")); } + + /// Verify that agenda day boundaries use local timezone offsets, not UTC. + #[test] + fn agenda_day_boundaries_use_local_timezone() { + use chrono::{Local, NaiveTime, TimeZone}; + + let local_now = Local::now(); + let today_start = local_now + .date_naive() + .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); + let today_start_local = Local + .from_local_datetime(&today_start) + .single() + .unwrap_or(local_now); + + let today_rfc = today_start_local.to_rfc3339(); + let tomorrow_start = today_start_local + chrono::Duration::days(1); + let tomorrow_rfc = tomorrow_start.to_rfc3339(); + + // The local offset should appear in the RFC3339 string (e.g. -07:00, +05:30). + // If the code were using UTC, the string would end with +00:00 (unless + // the machine is actually in UTC, in which case this test is a no-op). + let local_offset = local_now.format("%:z").to_string(); + assert!( + today_rfc.contains(&local_offset), + "today boundary should carry local offset {local_offset}, got {today_rfc}" + ); + assert!( + tomorrow_rfc.contains(&local_offset), + "tomorrow boundary should carry local offset {local_offset}, got {tomorrow_rfc}" + ); + } } From 2ab00d35a9b37040c84632f2490be4ba1d559fc3 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 11:53:58 +0530 Subject: [PATCH 2/2] fix: use earliest() for DST-safe local time resolution Replace .single().unwrap_or(local_now) with .earliest().unwrap_or(local_now) to correctly handle DST transitions where midnight may be ambiguous or non-existent. Applied in both production code and test. --- src/helpers/calendar.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index 2dfb20ea..8b07fbd5 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -208,9 +208,12 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let today_start = local_now .date_naive() .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); + // Use .earliest() to handle DST transitions where midnight may be + // ambiguous or non-existent. Falls back to current time if resolution + // fails entirely (should not happen for midnight in practice). let today_start_local = Local .from_local_datetime(&today_start) - .single() + .earliest() .unwrap_or(local_now); let days: i64 = if matches.get_flag("tomorrow") { @@ -554,8 +557,8 @@ mod tests { .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); let today_start_local = Local .from_local_datetime(&today_start) - .single() - .unwrap_or(local_now); + .earliest() + .unwrap_or(local_now.into()); let today_rfc = today_start_local.to_rfc3339(); let tomorrow_start = today_start_local + chrono::Duration::days(1);