//! Time-zone resolution. //! //! Two questions live here, and keeping them apart is the point of the module. //! //! **Where is the user?** [`system_tz`] reads the OS zone. GoingsOn has no //! separate per-user zone preference, so this is the answer. //! //! **What zone is this event read in?** [`event_tz`], which is *not* always the //! answer to the first question. An event carries a [`TzKind`]: a `Local` event //! names its own zone and is read in that one whoever is looking, an `Absolute` //! event is a fixed instant, and only a `Relative` event follows the reader. //! Resolving every event against the system zone is what made the schedule a //! property of the reading machine rather than of the data. //! //! Recurrence advances dates in the resolved zone (see [`crate::recurrence`]) so //! a fixed local time-of-day, "every day at 09:00", survives daylight-saving //! transitions instead of drifting an hour. //! //! This lives in `core` rather than in the desktop app because the app is not the //! only process that resolves the zone: `go-mcp` is a headless peer writer against //! the same database, and a second copy of this logic is a second answer to "what //! zone is the user in" waiting to diverge. use crate::models::TzKind; use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc}; use chrono_tz::Tz; /// The user's IANA time zone, resolved from the OS. Falls back to UTC if the OS /// zone can't be read or doesn't parse as a known IANA name. pub fn system_tz() -> Tz { iana_time_zone::get_timezone() .ok() .and_then(|name| name.parse().ok()) .unwrap_or(Tz::UTC) } /// Today's civil date in the user's zone. /// /// The date a *period* is derived from -- this week, this month -- has to come /// from the same zone that later buckets rows into its days, or the two disagree /// for the hours when the UTC date has turned and the local one has not. Reading /// it off `Utc::now()` puts an evening west of Greenwich in tomorrow's week while /// every window query stays in today's, so the period being shown holds none of /// the rows that were just written into it. pub fn today_local() -> NaiveDate { Utc::now().with_timezone(&system_tz()).date_naive() } /// The zone an event's civil time should be read in. /// /// `Local` names its own zone, so it resolves the same for every reader; an /// unparseable or missing name falls back to the reader's zone rather than /// erroring, since a stored row should still render. `Relative` follows the /// reader by definition. `Absolute` has no civil time to interpret, and the /// system zone is only the zone it gets *displayed* in. /// /// `reader` is passed in rather than resolved here so a caller projecting a /// page of events reads the OS zone once instead of once per row. pub fn event_tz(kind: TzKind, timezone: Option<&str>, reader: Tz) -> Tz { match kind { TzKind::Local => timezone .and_then(|name| name.parse().ok()) .unwrap_or(reader), TzKind::Relative | TzKind::Absolute => reader, } } /// Materialize a civil wall-clock time into the UTC instant used for range /// queries, indexes and ordering. /// /// Derived, never authoritative: for `Local` it is a pure function of the civil /// time and the event's zone, and for `Relative` it is only true while the user /// stays put, which is why moving zones triggers a recompute. /// /// A civil time can be ambiguous (the hour repeated at a DST fall-back) or /// nonexistent (the hour skipped at spring-forward). Take the earliest valid /// instant, and on a gap fall back to reading the civil time as UTC rather than /// panicking -- an event in a skipped hour should still land somewhere sane. pub fn civil_to_utc_in(civil: NaiveDateTime, tz: Tz) -> DateTime { tz.from_local_datetime(&civil).earliest().map_or_else( || DateTime::::from_naive_utc_and_offset(civil, Utc), |dt| dt.with_timezone(&Utc), ) } /// Interpret a civil (wall-clock) datetime as being in the user's system zone /// and convert it to the corresponding UTC instant. /// /// Window queries (weekly/monthly review) work with civil dates like "the start /// of this week"; those midnights are local, not UTC, so stamping them as UTC /// misattributes edge-of-period rows by the zone offset. On a DST spring-forward /// gap the civil time doesn't exist, so fall back to treating it as UTC rather /// than panicking. pub fn local_civil_to_utc(civil: NaiveDateTime) -> DateTime { civil_to_utc_in(civil, system_tz()) } /// Recompute the UTC projection of every event whose civil time is the truth, /// for a user now reading in `reader`'s zone. Returns how many rows moved. /// /// This is what makes `Relative` mean anything: the civil column says 06:00, and /// until this runs the UTC column still says 06:00-in-Denver. `Local` rows are /// included because they are cheap and idempotent -- their materialization is a /// pure function of civil time and their own zone, so the pass confirms rather /// than changes them, and a row written by a peer that got the arithmetic wrong /// is repaired. /// /// Call on startup, gated on the zone having actually changed. Running it /// unconditionally is harmless but writes a changelog row per event. pub fn rematerialize_civil_events( repo: &dyn crate::repository::EventRepository, user_id: crate::id_types::UserId, reader: Tz, ) -> crate::Result { let events = repo.list_all(user_id)?; let mut moved = 0; for event in events { if !event.tz_kind.is_civil() { continue; } let mut next = event.clone(); next.rematerialize_in(reader); if next.start_time == event.start_time && next.end_time == event.end_time { continue; } repo.set_materialized_times(event.id, user_id, next.start_time, next.end_time)?; moved += 1; } Ok(moved) } #[cfg(test)] mod tests { use super::*; fn civil(s: &str) -> NaiveDateTime { NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").unwrap() } #[test] fn a_local_event_reads_in_its_own_zone_whoever_looks() { let denver: Tz = "America/Denver".parse().unwrap(); let tokyo: Tz = "Asia/Tokyo".parse().unwrap(); // Same event, two readers, one answer: that is the whole point of Local. assert_eq!( event_tz(TzKind::Local, Some("America/Denver"), tokyo), denver ); assert_eq!( event_tz(TzKind::Local, Some("America/Denver"), denver), denver ); } #[test] fn a_relative_event_follows_the_reader() { let tokyo: Tz = "Asia/Tokyo".parse().unwrap(); assert_eq!(event_tz(TzKind::Relative, None, tokyo), tokyo); // A stray zone name on a relative row is ignored rather than honoured; // the kind decides, not the leftover column. assert_eq!( event_tz(TzKind::Relative, Some("America/Denver"), tokyo), tokyo ); } #[test] fn an_unparseable_zone_falls_back_to_the_reader() { let tokyo: Tz = "Asia/Tokyo".parse().unwrap(); assert_eq!(event_tz(TzKind::Local, Some("Mars/Olympus"), tokyo), tokyo); assert_eq!(event_tz(TzKind::Local, None, tokyo), tokyo); } #[test] fn materializing_across_a_dst_gap_does_not_panic() { let denver: Tz = "America/Denver".parse().unwrap(); // 2026-03-08 02:30 does not exist in Denver: the clock jumps 02:00 -> 03:00. let got = civil_to_utc_in(civil("2026-03-08 02:30:00"), denver); assert_eq!(got.to_rfc3339(), "2026-03-08T02:30:00+00:00"); } #[test] fn materializing_an_ambiguous_hour_takes_the_earlier_instant() { let denver: Tz = "America/Denver".parse().unwrap(); // 2026-11-01 01:30 happens twice in Denver (MDT then MST). let got = civil_to_utc_in(civil("2026-11-01 01:30:00"), denver); assert_eq!(got.to_rfc3339(), "2026-11-01T07:30:00+00:00"); } #[test] fn a_relative_wall_clock_holds_across_a_move() { let denver: Tz = "America/Denver".parse().unwrap(); let lisbon: Tz = "Europe/Lisbon".parse().unwrap(); let wake = civil("2026-07-27 06:00:00"); // The civil time is the truth; each machine materializes its own UTC. let in_denver = civil_to_utc_in(wake, denver); let in_lisbon = civil_to_utc_in(wake, lisbon); assert_ne!(in_denver, in_lisbon, "a move changes the instant"); assert_eq!(in_denver.to_rfc3339(), "2026-07-27T12:00:00+00:00"); assert_eq!(in_lisbon.to_rfc3339(), "2026-07-27T05:00:00+00:00"); // ...and reading each back in its own zone gives 06:00 both times. assert_eq!( in_denver.with_timezone(&denver).format("%H:%M").to_string(), "06:00" ); assert_eq!( in_lisbon.with_timezone(&lisbon).format("%H:%M").to_string(), "06:00" ); } }