Skip to content

Commit d124eb1

Browse files
committed
feat(timezone): use Google account timezone for day-boundary calculations
Replace machine-local chrono::Local and UTC epoch math with the authenticated user's Google account timezone (Calendar Settings API). - Add chrono-tz dependency for IANA timezone parsing - New src/timezone.rs: resolve timezone with priority: --timezone flag > 24h cache > Calendar API > local fallback - calendar.rs: add --timezone/--tz flag to +agenda - workflows.rs: fix +standup-report, +weekly-digest, +meeting-prep - auth_commands.rs: invalidate timezone cache on logout - Update README.md and AGENTS.md with timezone docs Supersedes #369 and #462.
1 parent e4a59c1 commit d124eb1

File tree

11 files changed

+388
-83
lines changed

11 files changed

+388
-83
lines changed

.changeset/account-timezone.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
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.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ The CLI uses a **two-phase argument parsing** strategy:
5757
| `src/schema.rs` | `gws schema` command — introspect API method schemas |
5858
| `src/error.rs` | Structured JSON error output |
5959
| `src/logging.rs` | Opt-in structured logging (stderr + file) via `tracing` |
60+
| `src/timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API, 24h cache |
6061

6162
## Demo Videos
6263

Cargo.lock

Lines changed: 37 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ derive_builder = "0.20.2"
5454
ratatui = "0.30.0"
5555
crossterm = "0.29.0"
5656
chrono = "0.4.44"
57+
chrono-tz = "0.10"
58+
iana-time-zone = "0.1"
5759
async-trait = "0.1.89"
5860
serde_yaml = "0.9.34"
5961
percent-encoding = "2.3.2"

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ gws sheets spreadsheets values append \
296296
297297
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.
298298
299+
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.
300+
299301
Run `gws <service> --help` to see both Discovery methods and helper commands together.
300302
301303
```bash
@@ -320,7 +322,7 @@ gws drive --help # shows +upload …
320322
| `chat` | `+send` | Send a message to a space |
321323
| `drive` | `+upload` | Upload a file with automatic metadata |
322324
| `calendar` | `+insert` | Create a new event |
323-
| `calendar` | `+agenda` | Show upcoming events across all calendars |
325+
| `calendar` | `+agenda` | Show upcoming events (uses Google account timezone; override with `--timezone`) |
324326
| `script` | `+push` | Replace all files in an Apps Script project with local files |
325327
| `workflow` | `+standup-report` | Today's meetings + open tasks as a standup summary |
326328
| `workflow` | `+meeting-prep` | Prepare for your next meeting: agenda, attendees, and linked docs |
@@ -353,6 +355,9 @@ gws drive +upload ./report.pdf --name "Q1 Report"
353355
354356
# Morning standup summary
355357
gws workflow +standup-report
358+
359+
# Show today's agenda in a specific timezone
360+
gws calendar +agenda --today --timezone America/New_York
356361
```
357362
358363
### Model Armor (Response Sanitization)

skills/gws-calendar-agenda/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ gws calendar +agenda
3131
| `--week` ||| Show this week's events |
3232
| `--days` ||| Number of days ahead to show |
3333
| `--calendar` ||| Filter to specific calendar name or ID |
34+
| `--timezone` ||| IANA timezone override (e.g. America/Denver). Defaults to Google account timezone. |
3435

3536
## Examples
3637

@@ -39,12 +40,14 @@ gws calendar +agenda
3940
gws calendar +agenda --today
4041
gws calendar +agenda --week --format table
4142
gws calendar +agenda --days 3 --calendar 'Work'
43+
gws calendar +agenda --today --timezone America/New_York
4244
```
4345

4446
## Tips
4547

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

4952
## See Also
5053

src/auth_commands.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,9 @@ fn handle_logout() -> Result<(), GwsError> {
11911191
}
11921192
}
11931193

1194+
// Invalidate cached account timezone (may belong to old account)
1195+
crate::timezone::invalidate_cache();
1196+
11941197
let output = if removed.is_empty() {
11951198
json!({
11961199
"status": "success",

src/helpers/calendar.rs

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,26 @@ TIPS:
122122
.help("Filter to specific calendar name or ID")
123123
.value_name("NAME"),
124124
)
125+
.arg(
126+
Arg::new("timezone")
127+
.long("timezone")
128+
.alias("tz")
129+
.help("IANA timezone override (e.g. America/Denver). Defaults to Google account timezone.")
130+
.value_name("TZ"),
131+
)
125132
.after_help(
126133
"\
127134
EXAMPLES:
128135
gws calendar +agenda
129136
gws calendar +agenda --today
130137
gws calendar +agenda --week --format table
131138
gws calendar +agenda --days 3 --calendar 'Work'
139+
gws calendar +agenda --today --timezone America/New_York
132140
133141
TIPS:
134142
Read-only — never modifies events.
135-
Queries all calendars by default; use --calendar to filter.",
143+
Queries all calendars by default; use --calendar to filter.
144+
Uses your Google account timezone by default; override with --timezone.",
136145
),
137146
);
138147
cmd
@@ -201,21 +210,14 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
201210
.map(|s| crate::formatter::OutputFormat::from_str(s))
202211
.unwrap_or(crate::formatter::OutputFormat::Table);
203212

204-
// Determine time range using the local timezone so that --today and
205-
// --tomorrow align with the user's wall-clock day, not UTC.
206-
use chrono::{Local, NaiveTime, TimeZone};
207-
208-
let local_now = Local::now();
209-
let today_start = local_now
210-
.date_naive()
211-
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
212-
// Use .earliest() to handle DST transitions where midnight may be
213-
// ambiguous or non-existent. Falls back to current time if resolution
214-
// fails entirely (should not happen for midnight in practice).
215-
let today_start_local = Local
216-
.from_local_datetime(&today_start)
217-
.earliest()
218-
.unwrap_or(local_now);
213+
let client = crate::client::build_client()?;
214+
let tz_override = matches.get_one::<String>("timezone").map(|s| s.as_str());
215+
let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?;
216+
217+
// Determine time range using the account timezone so that --today and
218+
// --tomorrow align with the user's Google account day, not the machine.
219+
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
220+
let today_start_tz = crate::timezone::start_of_today(tz)?;
219221

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

231233
let (time_min_dt, time_max_dt) = if matches.get_flag("today") {
232-
// Today: local midnight to local midnight+1
233-
let end = today_start_local + chrono::Duration::days(1);
234-
(today_start_local, end)
234+
// Today: account tz midnight to midnight+1
235+
let end = today_start_tz + chrono::Duration::days(1);
236+
(today_start_tz, end)
235237
} else if matches.get_flag("tomorrow") {
236-
// Tomorrow: local midnight+1 to local midnight+2
237-
let start = today_start_local + chrono::Duration::days(1);
238-
let end = today_start_local + chrono::Duration::days(2);
238+
// Tomorrow: account tz midnight+1 to midnight+2
239+
let start = today_start_tz + chrono::Duration::days(1);
240+
let end = today_start_tz + chrono::Duration::days(2);
239241
(start, end)
240242
} else {
241243
// From now, N days ahead
242-
let end = local_now + chrono::Duration::days(days);
243-
(local_now, end)
244+
let end = now_in_tz + chrono::Duration::days(days);
245+
(now_in_tz, end)
244246
};
245247

246248
let time_min = time_min_dt.to_rfc3339();
247249
let time_max = time_max_dt.to_rfc3339();
248250

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

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

550-
/// Verify that agenda day boundaries use local timezone offsets, not UTC.
552+
/// Verify that agenda day boundaries use a specific timezone, not UTC.
551553
#[test]
552-
fn agenda_day_boundaries_use_local_timezone() {
553-
use chrono::{Local, NaiveTime, TimeZone};
554+
fn agenda_day_boundaries_use_account_timezone() {
555+
use chrono::{NaiveTime, TimeZone, Utc};
554556

555-
let local_now = Local::now();
556-
let today_start = local_now
557+
// Simulate using a known account timezone (America/Denver = UTC-7 / UTC-6 DST)
558+
let tz = chrono_tz::America::Denver;
559+
let now_in_tz = Utc::now().with_timezone(&tz);
560+
let today_start = now_in_tz
557561
.date_naive()
558562
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
559-
let today_start_local = Local
563+
let today_start_tz = tz
560564
.from_local_datetime(&today_start)
561565
.earliest()
562-
.unwrap_or(local_now.into());
566+
.expect("midnight should resolve");
563567

564-
let today_rfc = today_start_local.to_rfc3339();
565-
let tomorrow_start = today_start_local + chrono::Duration::days(1);
568+
let today_rfc = today_start_tz.to_rfc3339();
569+
let tomorrow_start = today_start_tz + chrono::Duration::days(1);
566570
let tomorrow_rfc = tomorrow_start.to_rfc3339();
567571

568-
// The local offset should appear in the RFC3339 string (e.g. -07:00, +05:30).
569-
// If the code were using UTC, the string would end with +00:00 (unless
570-
// the machine is actually in UTC, in which case this test is a no-op).
571-
let local_offset = local_now.format("%:z").to_string();
572+
// The Denver offset should appear in the RFC3339 string (-07:00 or -06:00 for DST).
573+
// Crucially, it should NOT be +00:00 (UTC).
572574
assert!(
573-
today_rfc.contains(&local_offset),
574-
"today boundary should carry local offset {local_offset}, got {today_rfc}"
575+
today_rfc.contains("-07:00") || today_rfc.contains("-06:00"),
576+
"today boundary should carry Denver offset, got {today_rfc}"
575577
);
576578
assert!(
577-
tomorrow_rfc.contains(&local_offset),
578-
"tomorrow boundary should carry local offset {local_offset}, got {tomorrow_rfc}"
579+
tomorrow_rfc.contains("-07:00") || tomorrow_rfc.contains("-06:00"),
580+
"tomorrow boundary should carry Denver offset, got {tomorrow_rfc}"
579581
);
580582
}
581583
}

0 commit comments

Comments
 (0)