fix(calendar): use local timezone for agenda day boundaries (#443)

* 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.

* 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.
This commit is contained in:
Anshul Garg
2026-03-14 00:44:34 +05:30
committed by GitHub
parent 21b18407b9
commit 8ef27a262c
2 changed files with 68 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
fix(calendar): use local timezone for agenda day boundaries instead of UTC
+63 -22
View File
@@ -200,36 +200,50 @@ 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();
// 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 days: u64 = if matches.get_flag("tomorrow") {
// Start from tomorrow, 1 day
let local_now = Local::now();
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)
.earliest()
.unwrap_or(local_now);
let days: i64 = if matches.get_flag("tomorrow") {
1
} else if matches.get_flag("week") {
7
} else {
matches
.get_one::<String>("days")
.and_then(|s| s.parse::<u64>().ok())
.and_then(|s| s.parse::<i64>().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::<String>("calendar");
@@ -395,11 +409,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 +545,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)
.earliest()
.unwrap_or(local_now.into());
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}"
);
}
}