From e9954d244b3483e7a405b1f11edb913b0092bb52 Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 3 Apr 2026 13:12:52 +0000 Subject: [PATCH] storkit: merge 466_story_configurable_timezone_in_project_toml_for_timer_scheduling --- Cargo.lock | 41 +++++++++++--- Cargo.toml | 1 + server/Cargo.toml | 1 + server/src/chat/timer.rs | 113 ++++++++++++++++++++++++++++++--------- server/src/config.rs | 11 ++++ server/src/worktree.rs | 12 +++++ 6 files changed, 147 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 154930ea..d5e9791f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[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" @@ -2726,7 +2736,16 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "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]] @@ -2736,7 +2755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -2745,7 +2764,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", ] @@ -2758,6 +2777,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" @@ -4083,6 +4111,7 @@ dependencies = [ "async-trait", "bytes", "chrono", + "chrono-tz", "eventsource-stream", "filetime", "futures", @@ -4124,7 +4153,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", "serde", ] @@ -4136,7 +4165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", ] @@ -5068,7 +5097,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/Cargo.toml b/Cargo.toml index b3263f9f..02fc2a0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ async-stream = "0.3" async-trait = "0.1.89" bytes = "1" chrono = { version = "0.4.44", features = ["serde"] } +chrono-tz = "0.10" eventsource-stream = "0.2.3" futures = "0.3" homedir = "0.3.6" diff --git a/server/Cargo.toml b/server/Cargo.toml index 6cd2677d..a4ba77d6 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,6 +9,7 @@ async-stream = { workspace = true } async-trait = { workspace = true } bytes = { workspace = true } chrono = { workspace = true, features = ["serde"] } +chrono-tz = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } homedir = { workspace = true } diff --git a/server/src/chat/timer.rs b/server/src/chat/timer.rs index 23c76861..44a47550 100644 --- a/server/src/chat/timer.rs +++ b/server/src/chat/timer.rs @@ -5,6 +5,7 @@ //! and command parsing / handling for the `timer` bot command. use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc}; +use chrono_tz::Tz; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -375,6 +376,12 @@ pub async fn handle_timer_command( store: &TimerStore, project_root: &Path, ) -> String { + // Load the configured timezone (if any) from project.toml. + let config_tz: Option = crate::config::ProjectConfig::load(project_root) + .ok() + .and_then(|c| c.timezone); + let tz_str: Option<&str> = config_tz.as_deref(); + match cmd { TimerCommand::Schedule { story_number_or_id, @@ -400,7 +407,7 @@ pub async fn handle_timer_command( ); } - let scheduled_at = match next_occurrence_of_hhmm(&hhmm) { + let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) { Some(t) => t, None => { return format!( @@ -411,10 +418,9 @@ pub async fn handle_timer_command( match store.add(story_id.clone(), scheduled_at) { Ok(()) => { - let local_time = scheduled_at.with_timezone(&Local); + let (display_time, tz_label) = format_in_timezone(scheduled_at, tz_str); format!( - "Timer set for **{story_id}** at **{}** (server local time).", - local_time.format("%Y-%m-%d %H:%M") + "Timer set for **{story_id}** at **{display_time}** ({tz_label})." ) } Err(e) => format!("Failed to save timer: {e}"), @@ -427,11 +433,11 @@ pub async fn handle_timer_command( } let mut lines = vec!["**Pending timers:**".to_string()]; for t in &timers { - let local_time = t.scheduled_at.with_timezone(&Local); + let (display_time, _) = format_in_timezone(t.scheduled_at, tz_str); lines.push(format!( "- **{}** → {}", t.story_id, - local_time.format("%Y-%m-%d %H:%M") + display_time )); } lines.join("\n") @@ -457,10 +463,10 @@ pub async fn handle_timer_command( // ── Helpers ──────────────────────────────────────────────────────────────── -/// Parse `HH:MM` and return the next UTC instant at which the server-local -/// clock will read that time. If the time has already passed today, returns -/// tomorrow's occurrence. -pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option> { +/// Parse `HH:MM` and return the next UTC instant at which the given timezone +/// (or the server-local clock when `timezone` is `None`) will read that time. +/// If the time has already passed today, returns tomorrow's occurrence. +pub fn next_occurrence_of_hhmm(hhmm: &str, timezone: Option<&str>) -> Option> { let (hh, mm) = hhmm.split_once(':')?; let hours: u32 = hh.parse().ok()?; let minutes: u32 = mm.parse().ok()?; @@ -468,17 +474,59 @@ pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option> { return None; } let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?; - let now_local = Local::now(); - let today = now_local.date_naive(); - let candidate = today.and_time(target_time); - let candidate_local = Local.from_local_datetime(&candidate).single()?; - if candidate_local > now_local { - Some(candidate_local.to_utc()) - } else { - let tomorrow = today + Duration::days(1); - let tomorrow_candidate = tomorrow.and_time(target_time); - let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?; - Some(tomorrow_local.to_utc()) + + match timezone.and_then(|s| s.parse::().ok()) { + Some(tz) => { + let now_tz = Utc::now().with_timezone(&tz); + let today = now_tz.date_naive(); + let candidate = today.and_time(target_time); + let candidate_tz = tz.from_local_datetime(&candidate).single()?; + if candidate_tz > now_tz { + Some(candidate_tz.to_utc()) + } else { + let tomorrow = today + Duration::days(1); + let tomorrow_candidate = tomorrow.and_time(target_time); + let tomorrow_tz = tz.from_local_datetime(&tomorrow_candidate).single()?; + Some(tomorrow_tz.to_utc()) + } + } + None => { + // Fall back to host/container local timezone. + let now_local = Local::now(); + let today = now_local.date_naive(); + let candidate = today.and_time(target_time); + let candidate_local = Local.from_local_datetime(&candidate).single()?; + if candidate_local > now_local { + Some(candidate_local.to_utc()) + } else { + let tomorrow = today + Duration::days(1); + let tomorrow_candidate = tomorrow.and_time(target_time); + let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?; + Some(tomorrow_local.to_utc()) + } + } + } +} + +/// Format a UTC instant for display in the given timezone (or local time when +/// `timezone` is `None`). Returns `(formatted_string, label)` where `label` +/// is either the IANA timezone name or `"server local time"`. +fn format_in_timezone(dt: DateTime, timezone: Option<&str>) -> (String, String) { + match timezone.and_then(|s| s.parse::().ok()) { + Some(tz) => { + let tz_time = dt.with_timezone(&tz); + ( + tz_time.format("%Y-%m-%d %H:%M").to_string(), + tz.to_string(), + ) + } + None => { + let local_time = dt.with_timezone(&Local); + ( + local_time.format("%Y-%m-%d %H:%M").to_string(), + "server local time".to_string(), + ) + } } } @@ -544,31 +592,44 @@ mod tests { #[test] fn valid_hhmm_returns_some() { - let result = next_occurrence_of_hhmm("14:30"); + let result = next_occurrence_of_hhmm("14:30", None); assert!(result.is_some(), "valid HH:MM should return Some"); } #[test] fn invalid_hhmm_missing_colon_returns_none() { - assert!(next_occurrence_of_hhmm("1430").is_none()); + assert!(next_occurrence_of_hhmm("1430", None).is_none()); } #[test] fn invalid_hhmm_bad_hours_returns_none() { - assert!(next_occurrence_of_hhmm("25:00").is_none()); + assert!(next_occurrence_of_hhmm("25:00", None).is_none()); } #[test] fn invalid_hhmm_bad_minutes_returns_none() { - assert!(next_occurrence_of_hhmm("12:60").is_none()); + assert!(next_occurrence_of_hhmm("12:60", None).is_none()); } #[test] fn next_occurrence_is_in_the_future() { - let result = next_occurrence_of_hhmm("14:30").unwrap(); + let result = next_occurrence_of_hhmm("14:30", None).unwrap(); assert!(result > Utc::now(), "next occurrence must be in the future"); } + #[test] + fn next_occurrence_with_named_timezone_is_in_the_future() { + let result = next_occurrence_of_hhmm("14:30", Some("Europe/London")).unwrap(); + assert!(result > Utc::now(), "next occurrence (Europe/London) must be in the future"); + } + + #[test] + fn next_occurrence_with_invalid_timezone_falls_back_to_local() { + // An unrecognised timezone name falls back to chrono::Local (returns Some). + let result = next_occurrence_of_hhmm("14:30", Some("Invalid/Zone")); + assert!(result.is_some(), "invalid timezone should fall back to local and return Some"); + } + // ── extract_timer_command ─────────────────────────────────────────── #[test] diff --git a/server/src/config.rs b/server/src/config.rs index c6d73801..896546a2 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -42,6 +42,11 @@ pub struct ProjectConfig { /// Default: `true`. #[serde(default = "default_rate_limit_notifications")] pub rate_limit_notifications: bool, + /// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`). + /// When set, timer HH:MM inputs are interpreted in this timezone instead + /// of the container/host local time. Falls back to `chrono::Local` when absent. + #[serde(default)] + pub timezone: Option, } /// Configuration for the filesystem watcher's sweep behaviour. @@ -184,6 +189,8 @@ struct LegacyProjectConfig { base_branch: Option, #[serde(default = "default_rate_limit_notifications")] rate_limit_notifications: bool, + #[serde(default)] + timezone: Option, } impl Default for ProjectConfig { @@ -212,6 +219,7 @@ impl Default for ProjectConfig { max_retries: default_max_retries(), base_branch: None, rate_limit_notifications: default_rate_limit_notifications(), + timezone: None, } } } @@ -259,6 +267,7 @@ impl ProjectConfig { max_retries: legacy.max_retries, base_branch: legacy.base_branch, rate_limit_notifications: legacy.rate_limit_notifications, + timezone: legacy.timezone, }; validate_agents(&config.agent)?; return Ok(config); @@ -285,6 +294,7 @@ impl ProjectConfig { max_retries: legacy.max_retries, base_branch: legacy.base_branch, rate_limit_notifications: legacy.rate_limit_notifications, + timezone: legacy.timezone, }; validate_agents(&config.agent)?; Ok(config) @@ -299,6 +309,7 @@ impl ProjectConfig { max_retries: legacy.max_retries, base_branch: legacy.base_branch, rate_limit_notifications: legacy.rate_limit_notifications, + timezone: legacy.timezone, }) } } diff --git a/server/src/worktree.rs b/server/src/worktree.rs index d1399d0d..16ae5867 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -527,6 +527,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // Should complete without panic run_setup_commands(tmp.path(), &config).await; @@ -550,6 +551,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // Should complete without panic run_setup_commands(tmp.path(), &config).await; @@ -573,6 +575,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // Setup command failures are non-fatal — should not panic or propagate run_setup_commands(tmp.path(), &config).await; @@ -596,6 +599,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // Teardown failures are best-effort — should not propagate assert!(run_teardown_commands(tmp.path(), &config).await.is_ok()); @@ -618,6 +622,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; let info = create_worktree(&project_root, "42_fresh_test", &config, 3001) .await @@ -647,6 +652,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // First creation let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001) @@ -717,6 +723,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await; @@ -745,6 +752,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; create_worktree(&project_root, "88_remove_by_id", &config, 3001) .await @@ -820,6 +828,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // Even though setup commands fail, create_worktree must succeed // so the agent can start and fix the problem itself. @@ -851,6 +860,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // First creation — no setup commands, should succeed create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001) @@ -872,6 +882,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; // Second call — worktree exists, setup commands fail, must still succeed let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await; @@ -899,6 +910,7 @@ mod tests { max_retries: 2, base_branch: None, rate_limit_notifications: true, + timezone: None, }; let info = create_worktree(&project_root, "77_remove_async", &config, 3001) .await