diff --git a/.changeset/fix-workflow-timezone.md b/.changeset/fix-workflow-timezone.md new file mode 100644 index 00000000..9465a7dd --- /dev/null +++ b/.changeset/fix-workflow-timezone.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Use local timezone for standup-report and weekly-digest day boundaries diff --git a/src/helpers/workflows.rs b/src/helpers/workflows.rs index 05355d0d..2ec149f7 100644 --- a/src/helpers/workflows.rs +++ b/src/helpers/workflows.rs @@ -275,15 +275,12 @@ 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); + // Today's time range using local timezone so boundaries align with the + // user's wall-clock day, not UTC midnight. + let (today_start_local, today_end_local) = local_today_boundaries(); + + let time_min = today_start_local.to_rfc3339(); + let time_max = today_end_local.to_rfc3339(); // Fetch today's events let events_json = get_json( @@ -355,7 +352,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": today_start_local.format("%Y-%m-%d").to_string(), }); format_and_print(&output, matches); @@ -542,13 +539,11 @@ 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); + // Use local time so the period boundaries match the user's timezone. + let local_now = chrono::Local::now(); + let week_end = local_now + chrono::Duration::days(7); + let time_min = local_now.to_rfc3339(); + let time_max = week_end.to_rfc3339(); // Fetch this week's events let events_json = get_json( @@ -692,6 +687,25 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> { // Utilities // --------------------------------------------------------------------------- +/// Returns (start_of_today, end_of_today) in the local timezone. +/// +/// Uses `chrono::Local` to derive midnight boundaries from the user's +/// wall-clock time. Handles DST transitions via `.earliest()`. +fn local_today_boundaries() -> (chrono::DateTime, chrono::DateTime) { + use chrono::{Local, NaiveTime, TimeZone}; + + let local_now = Local::now(); + let today_midnight = local_now + .date_naive() + .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); + let today_start = Local + .from_local_datetime(&today_midnight) + .earliest() + .expect("midnight should be representable in the local timezone"); + let today_end = today_start + chrono::Duration::days(1); + (today_start, today_end) +} + fn epoch_to_rfc3339(epoch: u64) -> String { use chrono::{TimeZone, Utc}; Utc.timestamp_opt(epoch as i64, 0).unwrap().to_rfc3339() @@ -729,6 +743,30 @@ mod tests { assert_eq!(epoch_to_rfc3339(1710000000), "2024-03-09T16:00:00+00:00"); } + #[test] + fn test_local_today_boundaries() { + // Verify that the shared helper produces boundaries that + // contain "now" and span ~24 hours. + let local_now = chrono::Local::now(); + let (start, end) = local_today_boundaries(); + + // "now" must fall within [start, end) + assert!(local_now >= start); + assert!(local_now < end); + + // The span is 24h (86400s) on most days, but 23h or 25h on DST + // transition days. Accept a range to avoid flaky tests. + let span = end.signed_duration_since(start).num_seconds(); + assert!( + (82800..=90000).contains(&span), + "span {span}s is outside the expected 23h–25h range" + ); + + // The RFC 3339 output must include the local offset + let rfc = start.to_rfc3339(); + assert!(rfc.contains('T')); + } + #[test] fn test_build_standup_report_cmd() { let cmd = build_standup_report_cmd();