From 40307a60d87c412376615f393dac2a2ebeb3ac42 Mon Sep 17 00:00:00 2001 From: jorj-pineda Date: Tue, 31 Mar 2026 23:47:31 -0500 Subject: [PATCH 1/4] Normalized all day event date extraction and allDay field --- .../src/helpers/calendar.rs | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index cf28b249..a63d8453 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -206,6 +206,49 @@ TIPS: }) } } +/// Extract start/end times from a Google Calendar event. +/// +/// All-day events use the `date` field (e.g. `"2026-03-23"`), while timed +/// events use `dateTime` (RFC 3339). We prefer `date` when present so that +/// all-day dates are never shifted by a timezone offset. +/// +/// Returns `(start, end, all_day)`. +fn extract_event_times(event: &Value) -> (String, String, bool) { + let start_obj = event.get("start"); + let end_obj = event.get("end"); + + // All-day events carry a `date` field with no `dateTime`. + let all_day = start_obj + .map(|s| s.get("date").is_some() && s.get("dateTime").is_none()) + .unwrap_or(false); + + let start = start_obj + .and_then(|s| { + if all_day { + s.get("date") + } else { + s.get("dateTime").or_else(|| s.get("date")) + } + }) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let end = end_obj + .and_then(|s| { + if all_day { + s.get("date") + } else { + s.get("dateTime").or_else(|| s.get("date")) + } + }) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + (start, end, all_day) +} + async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; let token = auth::get_token(&[cal_scope]) @@ -254,6 +297,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let time_min = time_min_dt.to_rfc3339(); let time_max = time_max_dt.to_rfc3339(); + let tz_name = tz.to_string(); // client already built above for timezone resolution let calendar_filter = matches.get_one::("calendar"); @@ -325,6 +369,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let token = &token; let time_min = &time_min; let time_max = &time_max; + let tz_name = &tz_name; async move { let events_url = format!( "https://www.googleapis.com/calendar/v3/calendars/{}/events", @@ -337,6 +382,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { .query(&[ ("timeMin", time_min.as_str()), ("timeMax", time_max.as_str()), + ("timeZone", tz_name.as_str()), ("singleEvents", "true"), ("orderBy", "startTime"), ("maxResults", "50"), @@ -358,18 +404,8 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let mut events = Vec::new(); if let Some(items) = events_json.get("items").and_then(|i| i.as_array()) { for event in items { - let start = event - .get("start") - .and_then(|s| s.get("dateTime").or_else(|| s.get("date"))) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let end = event - .get("end") - .and_then(|s| s.get("dateTime").or_else(|| s.get("date"))) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + let (start, end, all_day) = + extract_event_times(event); let summary = event .get("summary") .and_then(|v| v.as_str()) @@ -384,6 +420,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { events.push(json!({ "start": start, "end": end, + "allDay": all_day, "summary": summary, "calendar": cal.summary, "location": location, From cd7dc8a367bdd62d0c34b762f93fb3b8b12fe25a Mon Sep 17 00:00:00 2001 From: jorj-pineda Date: Tue, 31 Mar 2026 23:48:00 -0500 Subject: [PATCH 2/4] Added unit tests for date extraction logic --- .changeset/fix-allday-event-date.md | 5 ++ .../src/helpers/calendar.rs | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .changeset/fix-allday-event-date.md diff --git a/.changeset/fix-allday-event-date.md b/.changeset/fix-allday-event-date.md new file mode 100644 index 00000000..0bfec980 --- /dev/null +++ b/.changeset/fix-allday-event-date.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Fix all-day calendar events showing incorrect dates in `+agenda` by passing the user's timezone to the Calendar API diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index a63d8453..a988eea9 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -806,4 +806,53 @@ mod tests { "tomorrow boundary should carry Denver offset, got {tomorrow_rfc}" ); } + + #[test] + fn extract_event_times_all_day() { + let event = json!({ + "start": { "date": "2026-03-23" }, + "end": { "date": "2026-03-24" }, + "summary": "All-day event" + }); + let (start, end, all_day) = extract_event_times(&event); + assert_eq!(start, "2026-03-23"); + assert_eq!(end, "2026-03-24"); + assert!(all_day); + } + + #[test] + fn extract_event_times_timed() { + let event = json!({ + "start": { "dateTime": "2026-03-23T10:00:00+09:00" }, + "end": { "dateTime": "2026-03-23T11:00:00+09:00" }, + "summary": "Timed event" + }); + let (start, end, all_day) = extract_event_times(&event); + assert_eq!(start, "2026-03-23T10:00:00+09:00"); + assert_eq!(end, "2026-03-23T11:00:00+09:00"); + assert!(!all_day); + } + + #[test] + fn extract_event_times_missing_fields() { + let event = json!({ "summary": "No dates" }); + let (start, end, all_day) = extract_event_times(&event); + assert_eq!(start, ""); + assert_eq!(end, ""); + assert!(!all_day); + } + + #[test] + fn extract_event_times_all_day_prefers_date_over_datetime() { + // Regression: if both `date` and `dateTime` exist on an all-day + // event, the bare date must win so no timezone shift occurs. + let event = json!({ + "start": { "date": "2026-03-23" }, + "end": { "date": "2026-03-24" }, + "summary": "Mixed" + }); + let (start, _end, all_day) = extract_event_times(&event); + assert!(all_day); + assert_eq!(start, "2026-03-23", "bare date must not be shifted"); + } } From a612e0f61e212791d564ad511e09b0e19c3bd52e Mon Sep 17 00:00:00 2001 From: jorj-pineda Date: Wed, 1 Apr 2026 00:18:06 -0500 Subject: [PATCH 3/4] chore: apply cargo fmt --- crates/google-workspace-cli/src/helpers/calendar.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index a988eea9..7a29a217 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -404,8 +404,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let mut events = Vec::new(); if let Some(items) = events_json.get("items").and_then(|i| i.as_array()) { for event in items { - let (start, end, all_day) = - extract_event_times(event); + let (start, end, all_day) = extract_event_times(event); let summary = event .get("summary") .and_then(|v| v.as_str()) From 6091bd28503892d060993dbfcb581a019bc9e55d Mon Sep 17 00:00:00 2001 From: jorj-pineda Date: Wed, 1 Apr 2026 09:17:25 -0500 Subject: [PATCH 4/4] Fix all_day detection, & Regression test --- crates/google-workspace-cli/src/helpers/calendar.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index 7a29a217..9f6ae9fd 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -217,9 +217,9 @@ fn extract_event_times(event: &Value) -> (String, String, bool) { let start_obj = event.get("start"); let end_obj = event.get("end"); - // All-day events carry a `date` field with no `dateTime`. + // All-day events carry a `date` field; prefer it over `dateTime` when present. let all_day = start_obj - .map(|s| s.get("date").is_some() && s.get("dateTime").is_none()) + .map(|s| s.get("date").is_some()) .unwrap_or(false); let start = start_obj @@ -846,8 +846,8 @@ mod tests { // Regression: if both `date` and `dateTime` exist on an all-day // event, the bare date must win so no timezone shift occurs. let event = json!({ - "start": { "date": "2026-03-23" }, - "end": { "date": "2026-03-24" }, + "start": { "date": "2026-03-23", "dateTime": "2026-03-21T15:00:00Z" }, + "end": { "date": "2026-03-24", "dateTime": "2026-03-22T15:00:00Z" }, "summary": "Mixed" }); let (start, _end, all_day) = extract_event_times(&event);