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)
}
Comment on lines +216 to +250
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic in extract_event_times for detecting all-day events and extracting their values is redundant and inconsistent with the stated goal of preferring the date field when both date and dateTime are present.

Currently, all_day is only true if dateTime is absent. If both are present, all_day becomes false, and the extraction logic then prefers dateTime. This contradicts the comment on line 212 and the intent of the regression test on line 845. Additionally, the extraction of the end time is fragile because it relies on the all_day flag derived solely from the start object.

A simpler and more robust approach is to prefer the date field if it exists, which correctly identifies all-day events and avoids timezone shifts as intended.

fn extract_event_times(event: &Value) -> (String, String, bool) {
    let start_obj = event.get("start");
    let end_obj = event.get("end");

    // Prefer 'date' (all-day) over 'dateTime' (timed) to avoid timezone shifts.
    let start_date = start_obj.and_then(|s| s.get("date")).and_then(|v| v.as_str());
    let start_time = start_obj.and_then(|s| s.get("dateTime")).and_then(|v| v.as_str());
    let all_day = start_date.is_some();

    let start = start_date.or(start_time).unwrap_or("").to_string();
    let end = end_obj
        .and_then(|s| s.get("date").or_else(|| s.get("dateTime")))
        .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");
}
Comment on lines +845 to +856
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This regression test does not actually test the scenario where both date and dateTime are present, as the mock JSON only contains the date field. To properly verify that the bare date is preferred over dateTime, the test case should include both fields in the input.

    #[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-23T10:00:00Z" },
            "end": { "date": "2026-03-24", "dateTime": "2026-03-24T10: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");
        assert_eq!(end, "2026-03-24");
    }

}
Loading