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
5 changes: 5 additions & 0 deletions .changeset/fix-allday-event-date.md
Original file line number Diff line number Diff line change
@@ -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
109 changes: 97 additions & 12 deletions crates/google-workspace-cli/src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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; prefer it over `dateTime` when present.
let all_day = start_obj
.map(|s| s.get("date").is_some())
.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])
Expand Down Expand Up @@ -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::<String>("calendar");
Expand Down Expand Up @@ -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",
Expand All @@ -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"),
Expand All @@ -358,18 +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 = 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())
Expand All @@ -384,6 +419,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,
Expand Down Expand Up @@ -769,4 +805,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", "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);
assert!(all_day);
assert_eq!(start, "2026-03-23", "bare date must not be shifted");
}
}
Loading