feat(timezone): use Google account timezone for day-boundary calculations (#480)
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. Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+37
-7
@@ -259,6 +259,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -433,7 +443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
|
||||
dependencies = [
|
||||
"lab",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -884,6 +894,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"derive_builder",
|
||||
@@ -891,6 +902,7 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"iana-time-zone",
|
||||
"keyring",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
@@ -1668,7 +1680,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1678,7 +1699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1687,7 +1708,7 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
@@ -1698,7 +1719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -1713,6 +1734,15 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2562,7 +2592,7 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"nom",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
@@ -2599,7 +2629,7 @@ dependencies = [
|
||||
"ordered-float",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"sha2",
|
||||
"signal-hook",
|
||||
"siphasher",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+43
-41
@@ -122,6 +122,13 @@ 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:
|
||||
@@ -129,10 +136,12 @@ EXAMPLES:
|
||||
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
|
||||
@@ -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 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?;
|
||||
|
||||
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);
|
||||
// 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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-33
@@ -275,15 +275,13 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
|
||||
// Today's time range
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let day_start = (now / 86400) * 86400;
|
||||
let day_end = day_start + 86400;
|
||||
let time_min = epoch_to_rfc3339(day_start);
|
||||
let time_max = epoch_to_rfc3339(day_end);
|
||||
// Resolve account timezone for day boundaries
|
||||
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
|
||||
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
|
||||
let today_start_tz = crate::timezone::start_of_today(tz)?;
|
||||
let today_end_tz = today_start_tz + chrono::Duration::days(1);
|
||||
let time_min = today_start_tz.to_rfc3339();
|
||||
let time_max = today_end_tz.to_rfc3339();
|
||||
|
||||
// Fetch today's events
|
||||
let events_json = get_json(
|
||||
@@ -355,7 +353,7 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
"meetingCount": meetings.len(),
|
||||
"tasks": open_tasks,
|
||||
"taskCount": open_tasks.len(),
|
||||
"date": time_min.split('T').next().unwrap_or(""),
|
||||
"date": now_in_tz.format("%Y-%m-%d").to_string(),
|
||||
});
|
||||
|
||||
format_and_print(&output, matches);
|
||||
@@ -374,13 +372,9 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("primary");
|
||||
|
||||
// Fetch next upcoming event
|
||||
let now_rfc = epoch_to_rfc3339(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
);
|
||||
// Use account timezone for current time
|
||||
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
|
||||
let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339();
|
||||
|
||||
let events_url = format!(
|
||||
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
|
||||
@@ -542,13 +536,12 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let week_end = now + 7 * 86400;
|
||||
let time_min = epoch_to_rfc3339(now);
|
||||
let time_max = epoch_to_rfc3339(week_end);
|
||||
// Resolve account timezone for week boundaries
|
||||
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
|
||||
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
|
||||
let week_end = now_in_tz + chrono::Duration::days(7);
|
||||
let time_min = now_in_tz.to_rfc3339();
|
||||
let time_max = week_end.to_rfc3339();
|
||||
|
||||
// Fetch this week's events
|
||||
let events_json = get_json(
|
||||
@@ -692,10 +685,7 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn epoch_to_rfc3339(epoch: u64) -> String {
|
||||
use chrono::{TimeZone, Utc};
|
||||
Utc.timestamp_opt(epoch as i64, 0).unwrap().to_rfc3339()
|
||||
}
|
||||
// (epoch_to_rfc3339 removed — replaced by account timezone resolution)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -723,11 +713,7 @@ mod tests {
|
||||
assert!(WorkflowHelper.helper_only());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_epoch_to_rfc3339() {
|
||||
assert_eq!(epoch_to_rfc3339(0), "1970-01-01T00:00:00+00:00");
|
||||
assert_eq!(epoch_to_rfc3339(1710000000), "2024-03-09T16:00:00+00:00");
|
||||
}
|
||||
// (test_epoch_to_rfc3339 removed — function replaced by timezone resolution)
|
||||
|
||||
#[test]
|
||||
fn test_build_standup_report_cmd() {
|
||||
|
||||
@@ -38,6 +38,7 @@ mod services;
|
||||
mod setup;
|
||||
mod setup_tui;
|
||||
mod text;
|
||||
mod timezone;
|
||||
mod token_storage;
|
||||
pub(crate) mod validate;
|
||||
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Account timezone resolution for Google Workspace CLI.
|
||||
//!
|
||||
//! Resolves the authenticated user's timezone with the following priority:
|
||||
//! 1. Explicit `--timezone` CLI flag (hard error if invalid)
|
||||
//! 2. Cached value from config dir (24h TTL)
|
||||
//! 3. Google Calendar Settings API (`users/me/settings/timezone`)
|
||||
//! 4. Machine-local timezone (fallback with warning)
|
||||
|
||||
use crate::error::GwsError;
|
||||
use chrono_tz::Tz;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cache filename stored in the gws config directory.
|
||||
const CACHE_FILENAME: &str = "account_timezone";
|
||||
|
||||
/// Cache TTL in seconds (24 hours).
|
||||
const CACHE_TTL_SECS: u64 = 86400;
|
||||
|
||||
/// Returns the path to the timezone cache file.
|
||||
fn cache_path() -> PathBuf {
|
||||
crate::auth_commands::config_dir().join(CACHE_FILENAME)
|
||||
}
|
||||
|
||||
/// Remove the cached timezone file. Called on auth login/logout to
|
||||
/// invalidate stale values when the account changes.
|
||||
pub fn invalidate_cache() {
|
||||
let path = cache_path();
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the cached timezone if it exists and is fresh (< 24h old).
|
||||
fn read_cache() -> Option<Tz> {
|
||||
let path = cache_path();
|
||||
let metadata = std::fs::metadata(&path).ok()?;
|
||||
let modified = metadata.modified().ok()?;
|
||||
let age = std::time::SystemTime::now().duration_since(modified).ok()?;
|
||||
if age.as_secs() > CACHE_TTL_SECS {
|
||||
return None;
|
||||
}
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
let tz_name = contents.trim();
|
||||
tz_name.parse::<Tz>().ok()
|
||||
}
|
||||
|
||||
/// Write a timezone name to the cache file.
|
||||
fn write_cache(tz_name: &str) {
|
||||
let path = cache_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
tracing::warn!(path = %parent.display(), error = %e, "failed to create timezone cache directory");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, tz_name) {
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to write timezone cache");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the account timezone from the Google Calendar Settings API.
|
||||
async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result<Tz, GwsError> {
|
||||
let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone";
|
||||
let resp = client
|
||||
.get(url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch account timezone: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: status.as_u16(),
|
||||
message: body,
|
||||
reason: "timezone_fetch_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse timezone response: {e}")))?;
|
||||
|
||||
let tz_name = json
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
GwsError::Other(anyhow::anyhow!(
|
||||
"Timezone setting missing or empty 'value' field"
|
||||
))
|
||||
})?;
|
||||
|
||||
let tz: Tz = tz_name.parse().map_err(|_| {
|
||||
GwsError::Other(anyhow::anyhow!(
|
||||
"Google returned unrecognized timezone: {tz_name}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Cache for future use
|
||||
write_cache(tz_name);
|
||||
tracing::info!(
|
||||
timezone = tz_name,
|
||||
source = "calendar_api",
|
||||
"resolved account timezone"
|
||||
);
|
||||
|
||||
Ok(tz)
|
||||
}
|
||||
|
||||
/// Parse an explicit timezone string, returning an error if invalid.
|
||||
pub fn parse_timezone(tz_str: &str) -> Result<Tz, GwsError> {
|
||||
tz_str.parse::<Tz>().map_err(|_| {
|
||||
GwsError::Validation(format!(
|
||||
"Invalid timezone '{tz_str}'. Use an IANA timezone name (e.g. America/Denver, Europe/London, UTC)."
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the user's timezone with this priority:
|
||||
/// 1. `tz_override` (from `--timezone` flag) — hard error if invalid
|
||||
/// 2. Cached value in config dir — use if < 24h old
|
||||
/// 3. Google Calendar Settings API — fetch and cache
|
||||
/// 4. Machine-local timezone (log warning)
|
||||
pub async fn resolve_account_timezone(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
tz_override: Option<&str>,
|
||||
) -> Result<Tz, GwsError> {
|
||||
// 1. Explicit override — fail if invalid
|
||||
if let Some(tz_str) = tz_override {
|
||||
let tz = parse_timezone(tz_str)?;
|
||||
tracing::info!(
|
||||
timezone = tz_str,
|
||||
source = "cli_flag",
|
||||
"using explicit timezone"
|
||||
);
|
||||
return Ok(tz);
|
||||
}
|
||||
|
||||
// 2. Check cache
|
||||
if let Some(tz) = read_cache() {
|
||||
tracing::debug!(timezone = %tz, source = "cache", "using cached timezone");
|
||||
return Ok(tz);
|
||||
}
|
||||
|
||||
// 3. Fetch from Calendar Settings API
|
||||
match fetch_account_timezone(client, token).await {
|
||||
Ok(tz) => return Ok(tz),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch account timezone, falling back to local");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall back to machine-local timezone
|
||||
let local_iana = iana_time_zone_fallback();
|
||||
tracing::warn!(
|
||||
timezone = local_iana.as_str(),
|
||||
source = "local_machine",
|
||||
"using machine-local timezone as fallback"
|
||||
);
|
||||
let tz: Tz = local_iana.parse().unwrap_or(chrono_tz::UTC);
|
||||
Ok(tz)
|
||||
}
|
||||
|
||||
/// Return the start of today (midnight) in the given timezone as a
|
||||
/// timezone-aware `DateTime`. Errors if midnight cannot be resolved
|
||||
/// (e.g. a DST transition that skips midnight — extremely rare).
|
||||
pub fn start_of_today(tz: Tz) -> Result<chrono::DateTime<Tz>, crate::error::GwsError> {
|
||||
use chrono::{NaiveTime, TimeZone, Utc};
|
||||
|
||||
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());
|
||||
tz.from_local_datetime(&today_start)
|
||||
.earliest()
|
||||
.ok_or_else(|| {
|
||||
crate::error::GwsError::Other(anyhow::anyhow!(
|
||||
"Could not determine start of day in timezone '{}'",
|
||||
tz
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort machine-local IANA timezone detection using the
|
||||
/// `iana-time-zone` crate, which reads the OS timezone database.
|
||||
fn iana_time_zone_fallback() -> String {
|
||||
match iana_time_zone::get_timezone() {
|
||||
Ok(tz) => tz,
|
||||
Err(_) => "UTC".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_valid_iana_timezone() {
|
||||
let tz = parse_timezone("America/Denver").unwrap();
|
||||
assert_eq!(tz, chrono_tz::America::Denver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_utc_timezone() {
|
||||
let tz = parse_timezone("UTC").unwrap();
|
||||
assert_eq!(tz, chrono_tz::UTC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_timezone_fails() {
|
||||
let result = parse_timezone("Not/A/Zone");
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("Invalid timezone"));
|
||||
assert!(err.contains("Not/A/Zone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_string_fails() {
|
||||
let result = parse_timezone("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache_file = dir.path().join(CACHE_FILENAME);
|
||||
|
||||
// Write directly to test location
|
||||
std::fs::write(&cache_file, "America/New_York").unwrap();
|
||||
let contents = std::fs::read_to_string(&cache_file).unwrap();
|
||||
let tz: Tz = contents.trim().parse().unwrap();
|
||||
assert_eq!(tz, chrono_tz::America::New_York);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iana_fallback_returns_valid_tz() {
|
||||
let tz_name = iana_time_zone_fallback();
|
||||
// Should be parseable
|
||||
let result: Result<Tz, _> = tz_name.parse();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Fallback timezone '{tz_name}' should be parseable"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user