mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
feat(timezone): add timezone-aware session context (#671)
* 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]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a20e19ab16
commit
df3635d6be
+110
@@ -0,0 +1,110 @@
|
||||
//! 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user