mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat(timezone): add timezone-aware session context (#661) All timestamps were UTC-only, causing daily logs to split at UTC midnight, cron schedules to fire in UTC, and no quiet hours for heartbeat. This adds timezone as a per-session property flowing from the client. Key changes: - New `src/timezone.rs` module with resolution chain, parsing, and detection - `IncomingMessage` carries optional timezone from client - `JobContext.user_timezone` flows timezone to tools - `next_cron_fire()` accepts timezone for schedule evaluation - `Trigger::Cron` stores optional timezone (backward-compatible) - Workspace gains `_tz` variants for daily logs and system prompt - Heartbeat supports quiet hours (`HEARTBEAT_QUIET_START/END`) - Web frontend sends `Intl.DateTimeFormat().resolvedOptions().timeZone` - REPL auto-detects system timezone - `DEFAULT_TIMEZONE` env var / settings for server-wide default Storage stays UTC. Conversion happens at display boundaries. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): address review feedback on timezone-aware sessions - Validate quiet hours values (0-23) in HeartbeatConfig::resolve() - Fall back to settings values when env vars are unset for quiet hours - Validate IANA timezone strings in routine_create/update with parse_timezone - Add timezone field to routine_create tool schema - Allow standalone timezone update on cron routines without changing schedule - Return path from append_daily_log_tz to avoid TOCTOU race at midnight - Delegate append_daily_log to append_daily_log_tz(entry, UTC) to avoid drift - Preserve timezone through approval flow via PendingApproval.user_timezone - Improve test_today_in_tz to not depend on hardcoded year - Add 3 regression tests for quiet hours config validation Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in routine.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): address second round of review feedback - Remove .claude/scheduled_tasks.lock from repo and add to .gitignore - Store resolved timezone (not raw message.timezone) in PendingApproval - Carry forward user_timezone through chained approvals in thread_ops - Wire quiet_hours_start/end from config to HeartbeatRunner - Support X-Timezone header as fallback in chat_send_handler [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): include user's local time in time tool response The time tool's "now" operation now returns local_iso and timezone fields based on ctx.user_timezone, so the LLM can report time in the user's timezone instead of always UTC. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in time.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(timezone): address Copilot review round 3 — validation, deterministic tests, schema fixes - Validate DEFAULT_TIMEZONE and HEARTBEAT_TIMEZONE at config load time - Add timezone field to HeartbeatSettings and config::HeartbeatConfig - Wire heartbeat timezone from config through agent_loop to HeartbeatRunner - Add timezone to routine_update tool schema (was accepted but not advertised) - Error on schedule/timezone update for non-cron routines - Validate timezone in Trigger::from_db (coerce invalid to None with warning) - Validate timezone in approval path (thread_ops.rs) before overwriting - Time tool always includes timezone/local_iso fields (fallback to UTC) - Make quiet hours tests deterministic using current UTC hour - Add regression tests for config validation [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
111 lines
2.9 KiB
Rust
111 lines
2.9 KiB
Rust
//! Timezone resolution and utilities.
|
|
|
|
use chrono::{DateTime, NaiveDate, Utc};
|
|
use chrono_tz::Tz;
|
|
|
|
/// Resolve the effective timezone from a priority chain.
|
|
///
|
|
/// Priority: client_tz > user_setting > config_default > UTC
|
|
pub fn resolve_timezone(
|
|
client_tz: Option<&str>,
|
|
user_setting: Option<&str>,
|
|
config_default: &str,
|
|
) -> Tz {
|
|
// Try each in priority order, skipping invalid values
|
|
for candidate in [client_tz, user_setting, Some(config_default)] {
|
|
if let Some(tz) = candidate.and_then(parse_timezone) {
|
|
return tz;
|
|
}
|
|
}
|
|
Tz::UTC
|
|
}
|
|
|
|
/// Parse a timezone string (IANA name) into a `Tz`.
|
|
pub fn parse_timezone(s: &str) -> Option<Tz> {
|
|
s.parse::<Tz>().ok()
|
|
}
|
|
|
|
/// Get today's date in the given timezone.
|
|
pub fn today_in_tz(tz: Tz) -> NaiveDate {
|
|
Utc::now().with_timezone(&tz).date_naive()
|
|
}
|
|
|
|
/// Get the current time in the given timezone.
|
|
pub fn now_in_tz(tz: Tz) -> DateTime<Tz> {
|
|
Utc::now().with_timezone(&tz)
|
|
}
|
|
|
|
/// Detect the system's timezone, falling back to UTC.
|
|
pub fn detect_system_timezone() -> Tz {
|
|
iana_time_zone::get_timezone()
|
|
.ok()
|
|
.and_then(|s| parse_timezone(&s))
|
|
.unwrap_or(Tz::UTC)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use chrono::Datelike;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_resolve_client_wins() {
|
|
let tz = resolve_timezone(Some("America/New_York"), Some("Europe/London"), "UTC");
|
|
assert_eq!(tz, chrono_tz::America::New_York);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_user_setting_fallback() {
|
|
let tz = resolve_timezone(None, Some("Europe/London"), "UTC");
|
|
assert_eq!(tz, chrono_tz::Europe::London);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_config_fallback() {
|
|
let tz = resolve_timezone(None, None, "Asia/Tokyo");
|
|
assert_eq!(tz, chrono_tz::Asia::Tokyo);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_all_none_utc() {
|
|
let tz = resolve_timezone(None, None, "UTC");
|
|
assert_eq!(tz, Tz::UTC);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_invalid_client_skipped() {
|
|
let tz = resolve_timezone(Some("Fake/Zone"), Some("Europe/London"), "UTC");
|
|
assert_eq!(tz, chrono_tz::Europe::London);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_valid() {
|
|
assert_eq!(
|
|
parse_timezone("America/Chicago"),
|
|
Some(chrono_tz::America::Chicago)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_invalid() {
|
|
assert_eq!(parse_timezone("Fake/Zone"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_system_tz() {
|
|
// Should always return a valid Tz (at minimum UTC)
|
|
let tz = detect_system_timezone();
|
|
let _ = now_in_tz(tz); // Should not panic
|
|
}
|
|
|
|
#[test]
|
|
fn test_today_in_tz_returns_valid_date() {
|
|
let date = today_in_tz(Tz::UTC);
|
|
// Verify it returns a valid date (year, month, day are all positive)
|
|
assert!(date.year() > 0);
|
|
assert!((1..=12).contains(&date.month()));
|
|
assert!((1..=31).contains(&date.day()));
|
|
}
|
|
}
|