Skip to content
Merged
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/account-timezone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---

Use Google account timezone instead of machine-local time for day-boundary calculations in calendar and workflow helpers. Adds `--timezone` flag to `+agenda` for explicit override. Timezone is fetched from Calendar Settings API and cached for 24 hours.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The CLI uses a **two-phase argument parsing** strategy:
| `src/schema.rs` | `gws schema` command — introspect API method schemas |
| `src/error.rs` | Structured JSON error output |
| `src/logging.rs` | Opt-in structured logging (stderr + file) via `tracing` |
| `src/timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API, 24h cache |

## Demo Videos

Expand Down
44 changes: 37 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ derive_builder = "0.20.2"
ratatui = "0.30.0"
crossterm = "0.29.0"
chrono = "0.4.44"
chrono-tz = "0.10"
iana-time-zone = "0.1"
async-trait = "0.1.89"
serde_yaml = "0.9.34"
percent-encoding = "2.3.2"
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ gws sheets spreadsheets values append \

Some services ship hand-crafted helper commands alongside the auto-generated Discovery surface. Helper commands are prefixed with `+` so they are visually distinct and never collide with Discovery-generated method names.

Time-aware helpers (`+agenda`, `+standup-report`, `+weekly-digest`, `+meeting-prep`) automatically use your **Google account timezone** (fetched from Calendar Settings API and cached for 24 hours). Override with `--timezone`/`--tz` on `+agenda`, or set the `--timezone` flag for explicit control.

Run `gws <service> --help` to see both Discovery methods and helper commands together.

```bash
Expand All @@ -320,7 +322,7 @@ gws drive --help # shows +upload …
| `chat` | `+send` | Send a message to a space |
| `drive` | `+upload` | Upload a file with automatic metadata |
| `calendar` | `+insert` | Create a new event |
| `calendar` | `+agenda` | Show upcoming events across all calendars |
| `calendar` | `+agenda` | Show upcoming events (uses Google account timezone; override with `--timezone`) |
| `script` | `+push` | Replace all files in an Apps Script project with local files |
| `workflow` | `+standup-report` | Today's meetings + open tasks as a standup summary |
| `workflow` | `+meeting-prep` | Prepare for your next meeting: agenda, attendees, and linked docs |
Expand Down Expand Up @@ -353,6 +355,9 @@ gws drive +upload ./report.pdf --name "Q1 Report"

# Morning standup summary
gws workflow +standup-report

# Show today's agenda in a specific timezone
gws calendar +agenda --today --timezone America/New_York
```

### Model Armor (Response Sanitization)
Expand Down
3 changes: 3 additions & 0 deletions skills/gws-calendar-agenda/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ gws calendar +agenda
| `--week` | — | — | Show this week's events |
| `--days` | — | — | Number of days ahead to show |
| `--calendar` | — | — | Filter to specific calendar name or ID |
| `--timezone` | — | — | IANA timezone override (e.g. America/Denver). Defaults to Google account timezone. |

## Examples

Expand All @@ -39,12 +40,14 @@ gws calendar +agenda
gws calendar +agenda --today
gws calendar +agenda --week --format table
gws calendar +agenda --days 3 --calendar 'Work'
gws calendar +agenda --today --timezone America/New_York
```

## Tips

- Read-only — never modifies events.
- Queries all calendars by default; use --calendar to filter.
- Uses your Google account timezone by default; override with --timezone.

## See Also

Expand Down
3 changes: 3 additions & 0 deletions src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1191,6 +1191,9 @@ fn handle_logout() -> Result<(), GwsError> {
}
}

// Invalidate cached account timezone (may belong to old account)
crate::timezone::invalidate_cache();

let output = if removed.is_empty() {
json!({
"status": "success",
Expand Down
86 changes: 44 additions & 42 deletions src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,17 +122,26 @@ TIPS:
.help("Filter to specific calendar name or ID")
.value_name("NAME"),
)
.arg(
Arg::new("timezone")
.long("timezone")
.alias("tz")
.help("IANA timezone override (e.g. America/Denver). Defaults to Google account timezone.")
.value_name("TZ"),
)
.after_help(
"\
EXAMPLES:
gws calendar +agenda
gws calendar +agenda --today
gws calendar +agenda --week --format table
gws calendar +agenda --days 3 --calendar 'Work'
gws calendar +agenda --today --timezone America/New_York

TIPS:
Read-only — never modifies events.
Queries all calendars by default; use --calendar to filter.",
Queries all calendars by default; use --calendar to filter.
Uses your Google account timezone by default; override with --timezone.",
),
);
cmd
Expand Down Expand Up @@ -201,21 +210,14 @@ 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 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());
// 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 client = crate::client::build_client()?;
let tz_override = matches.get_one::<String>("timezone").map(|s| s.as_str());
let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?;

// Determine time range using the account timezone so that --today and
// --tomorrow align with the user's Google account day, not the machine.
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let today_start_tz = crate::timezone::start_of_today(tz)?;

let days: i64 = if matches.get_flag("tomorrow") {
1
Expand All @@ -229,24 +231,24 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
};

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)
// Today: account tz midnight to midnight+1
let end = today_start_tz + chrono::Duration::days(1);
(today_start_tz, 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);
// Tomorrow: account tz midnight+1 to midnight+2
let start = today_start_tz + chrono::Duration::days(1);
let end = today_start_tz + chrono::Duration::days(2);
(start, end)
} else {
// From now, N days ahead
let end = local_now + chrono::Duration::days(days);
(local_now, end)
let end = now_in_tz + chrono::Duration::days(days);
(now_in_tz, end)
};

let time_min = time_min_dt.to_rfc3339();
let time_max = time_max_dt.to_rfc3339();

let client = crate::client::build_client()?;
// client already built above for timezone resolution
let calendar_filter = matches.get_one::<String>("calendar");

// 1. List all calendars
Expand Down Expand Up @@ -547,35 +549,35 @@ mod tests {
assert!(body.contains("c@d.com"));
}

/// Verify that agenda day boundaries use local timezone offsets, not UTC.
/// Verify that agenda day boundaries use a specific timezone, not UTC.
#[test]
fn agenda_day_boundaries_use_local_timezone() {
use chrono::{Local, NaiveTime, TimeZone};
fn agenda_day_boundaries_use_account_timezone() {
use chrono::{NaiveTime, TimeZone, Utc};

let local_now = Local::now();
let today_start = local_now
// Simulate using a known account timezone (America/Denver = UTC-7 / UTC-6 DST)
let tz = chrono_tz::America::Denver;
let now_in_tz = Utc::now().with_timezone(&tz);
let today_start = now_in_tz
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
let today_start_local = Local
let today_start_tz = tz
.from_local_datetime(&today_start)
.earliest()
.unwrap_or(local_now.into());
.expect("midnight should resolve");

let today_rfc = today_start_local.to_rfc3339();
let tomorrow_start = today_start_local + chrono::Duration::days(1);
let today_rfc = today_start_tz.to_rfc3339();
let tomorrow_start = today_start_tz + 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();
// The Denver offset should appear in the RFC3339 string (-07:00 or -06:00 for DST).
// Crucially, it should NOT be +00:00 (UTC).
assert!(
today_rfc.contains(&local_offset),
"today boundary should carry local offset {local_offset}, got {today_rfc}"
today_rfc.contains("-07:00") || today_rfc.contains("-06:00"),
"today boundary should carry Denver offset, got {today_rfc}"
);
assert!(
tomorrow_rfc.contains(&local_offset),
"tomorrow boundary should carry local offset {local_offset}, got {tomorrow_rfc}"
tomorrow_rfc.contains("-07:00") || tomorrow_rfc.contains("-06:00"),
"tomorrow boundary should carry Denver offset, got {tomorrow_rfc}"
);
}
}
Loading
Loading