Skip to main content

max / goingson

8.8 KB · 213 lines History Blame Raw
1 //! Time-zone resolution.
2 //!
3 //! Two questions live here, and keeping them apart is the point of the module.
4 //!
5 //! **Where is the user?** [`system_tz`] reads the OS zone. GoingsOn has no
6 //! separate per-user zone preference, so this is the answer.
7 //!
8 //! **What zone is this event read in?** [`event_tz`], which is *not* always the
9 //! answer to the first question. An event carries a [`TzKind`]: a `Local` event
10 //! names its own zone and is read in that one whoever is looking, an `Absolute`
11 //! event is a fixed instant, and only a `Relative` event follows the reader.
12 //! Resolving every event against the system zone is what made the schedule a
13 //! property of the reading machine rather than of the data.
14 //!
15 //! Recurrence advances dates in the resolved zone (see [`crate::recurrence`]) so
16 //! a fixed local time-of-day, "every day at 09:00", survives daylight-saving
17 //! transitions instead of drifting an hour.
18 //!
19 //! This lives in `core` rather than in the desktop app because the app is not the
20 //! only process that resolves the zone: `go-mcp` is a headless peer writer against
21 //! the same database, and a second copy of this logic is a second answer to "what
22 //! zone is the user in" waiting to diverge.
23
24 use crate::models::TzKind;
25 use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
26 use chrono_tz::Tz;
27
28 /// The user's IANA time zone, resolved from the OS. Falls back to UTC if the OS
29 /// zone can't be read or doesn't parse as a known IANA name.
30 pub fn system_tz() -> Tz {
31 iana_time_zone::get_timezone()
32 .ok()
33 .and_then(|name| name.parse().ok())
34 .unwrap_or(Tz::UTC)
35 }
36
37 /// Today's civil date in the user's zone.
38 ///
39 /// The date a *period* is derived from -- this week, this month -- has to come
40 /// from the same zone that later buckets rows into its days, or the two disagree
41 /// for the hours when the UTC date has turned and the local one has not. Reading
42 /// it off `Utc::now()` puts an evening west of Greenwich in tomorrow's week while
43 /// every window query stays in today's, so the period being shown holds none of
44 /// the rows that were just written into it.
45 pub fn today_local() -> NaiveDate {
46 Utc::now().with_timezone(&system_tz()).date_naive()
47 }
48
49 /// The zone an event's civil time should be read in.
50 ///
51 /// `Local` names its own zone, so it resolves the same for every reader; an
52 /// unparseable or missing name falls back to the reader's zone rather than
53 /// erroring, since a stored row should still render. `Relative` follows the
54 /// reader by definition. `Absolute` has no civil time to interpret, and the
55 /// system zone is only the zone it gets *displayed* in.
56 ///
57 /// `reader` is passed in rather than resolved here so a caller projecting a
58 /// page of events reads the OS zone once instead of once per row.
59 pub fn event_tz(kind: TzKind, timezone: Option<&str>, reader: Tz) -> Tz {
60 match kind {
61 TzKind::Local => timezone
62 .and_then(|name| name.parse().ok())
63 .unwrap_or(reader),
64 TzKind::Relative | TzKind::Absolute => reader,
65 }
66 }
67
68 /// Materialize a civil wall-clock time into the UTC instant used for range
69 /// queries, indexes and ordering.
70 ///
71 /// Derived, never authoritative: for `Local` it is a pure function of the civil
72 /// time and the event's zone, and for `Relative` it is only true while the user
73 /// stays put, which is why moving zones triggers a recompute.
74 ///
75 /// A civil time can be ambiguous (the hour repeated at a DST fall-back) or
76 /// nonexistent (the hour skipped at spring-forward). Take the earliest valid
77 /// instant, and on a gap fall back to reading the civil time as UTC rather than
78 /// panicking -- an event in a skipped hour should still land somewhere sane.
79 pub fn civil_to_utc_in(civil: NaiveDateTime, tz: Tz) -> DateTime<Utc> {
80 tz.from_local_datetime(&civil).earliest().map_or_else(
81 || DateTime::<Utc>::from_naive_utc_and_offset(civil, Utc),
82 |dt| dt.with_timezone(&Utc),
83 )
84 }
85
86 /// Interpret a civil (wall-clock) datetime as being in the user's system zone
87 /// and convert it to the corresponding UTC instant.
88 ///
89 /// Window queries (weekly/monthly review) work with civil dates like "the start
90 /// of this week"; those midnights are local, not UTC, so stamping them as UTC
91 /// misattributes edge-of-period rows by the zone offset. On a DST spring-forward
92 /// gap the civil time doesn't exist, so fall back to treating it as UTC rather
93 /// than panicking.
94 pub fn local_civil_to_utc(civil: NaiveDateTime) -> DateTime<Utc> {
95 civil_to_utc_in(civil, system_tz())
96 }
97
98 /// Recompute the UTC projection of every event whose civil time is the truth,
99 /// for a user now reading in `reader`'s zone. Returns how many rows moved.
100 ///
101 /// This is what makes `Relative` mean anything: the civil column says 06:00, and
102 /// until this runs the UTC column still says 06:00-in-Denver. `Local` rows are
103 /// included because they are cheap and idempotent -- their materialization is a
104 /// pure function of civil time and their own zone, so the pass confirms rather
105 /// than changes them, and a row written by a peer that got the arithmetic wrong
106 /// is repaired.
107 ///
108 /// Call on startup, gated on the zone having actually changed. Running it
109 /// unconditionally is harmless but writes a changelog row per event.
110 pub fn rematerialize_civil_events(
111 repo: &dyn crate::repository::EventRepository,
112 user_id: crate::id_types::UserId,
113 reader: Tz,
114 ) -> crate::Result<usize> {
115 let events = repo.list_all(user_id)?;
116 let mut moved = 0;
117 for event in events {
118 if !event.tz_kind.is_civil() {
119 continue;
120 }
121 let mut next = event.clone();
122 next.rematerialize_in(reader);
123 if next.start_time == event.start_time && next.end_time == event.end_time {
124 continue;
125 }
126 repo.set_materialized_times(event.id, user_id, next.start_time, next.end_time)?;
127 moved += 1;
128 }
129 Ok(moved)
130 }
131
132 #[cfg(test)]
133 mod tests {
134 use super::*;
135
136 fn civil(s: &str) -> NaiveDateTime {
137 NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").unwrap()
138 }
139
140 #[test]
141 fn a_local_event_reads_in_its_own_zone_whoever_looks() {
142 let denver: Tz = "America/Denver".parse().unwrap();
143 let tokyo: Tz = "Asia/Tokyo".parse().unwrap();
144 // Same event, two readers, one answer: that is the whole point of Local.
145 assert_eq!(
146 event_tz(TzKind::Local, Some("America/Denver"), tokyo),
147 denver
148 );
149 assert_eq!(
150 event_tz(TzKind::Local, Some("America/Denver"), denver),
151 denver
152 );
153 }
154
155 #[test]
156 fn a_relative_event_follows_the_reader() {
157 let tokyo: Tz = "Asia/Tokyo".parse().unwrap();
158 assert_eq!(event_tz(TzKind::Relative, None, tokyo), tokyo);
159 // A stray zone name on a relative row is ignored rather than honoured;
160 // the kind decides, not the leftover column.
161 assert_eq!(
162 event_tz(TzKind::Relative, Some("America/Denver"), tokyo),
163 tokyo
164 );
165 }
166
167 #[test]
168 fn an_unparseable_zone_falls_back_to_the_reader() {
169 let tokyo: Tz = "Asia/Tokyo".parse().unwrap();
170 assert_eq!(event_tz(TzKind::Local, Some("Mars/Olympus"), tokyo), tokyo);
171 assert_eq!(event_tz(TzKind::Local, None, tokyo), tokyo);
172 }
173
174 #[test]
175 fn materializing_across_a_dst_gap_does_not_panic() {
176 let denver: Tz = "America/Denver".parse().unwrap();
177 // 2026-03-08 02:30 does not exist in Denver: the clock jumps 02:00 -> 03:00.
178 let got = civil_to_utc_in(civil("2026-03-08 02:30:00"), denver);
179 assert_eq!(got.to_rfc3339(), "2026-03-08T02:30:00+00:00");
180 }
181
182 #[test]
183 fn materializing_an_ambiguous_hour_takes_the_earlier_instant() {
184 let denver: Tz = "America/Denver".parse().unwrap();
185 // 2026-11-01 01:30 happens twice in Denver (MDT then MST).
186 let got = civil_to_utc_in(civil("2026-11-01 01:30:00"), denver);
187 assert_eq!(got.to_rfc3339(), "2026-11-01T07:30:00+00:00");
188 }
189
190 #[test]
191 fn a_relative_wall_clock_holds_across_a_move() {
192 let denver: Tz = "America/Denver".parse().unwrap();
193 let lisbon: Tz = "Europe/Lisbon".parse().unwrap();
194 let wake = civil("2026-07-27 06:00:00");
195
196 // The civil time is the truth; each machine materializes its own UTC.
197 let in_denver = civil_to_utc_in(wake, denver);
198 let in_lisbon = civil_to_utc_in(wake, lisbon);
199 assert_ne!(in_denver, in_lisbon, "a move changes the instant");
200 assert_eq!(in_denver.to_rfc3339(), "2026-07-27T12:00:00+00:00");
201 assert_eq!(in_lisbon.to_rfc3339(), "2026-07-27T05:00:00+00:00");
202 // ...and reading each back in its own zone gives 06:00 both times.
203 assert_eq!(
204 in_denver.with_timezone(&denver).format("%H:%M").to_string(),
205 "06:00"
206 );
207 assert_eq!(
208 in_lisbon.with_timezone(&lisbon).format("%H:%M").to_string(),
209 "06:00"
210 );
211 }
212 }
213