Skip to main content

caldir_core/event/
time.rs

1use super::tz_normalize::{self, Tzid};
2use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc};
3use icalendar::{CalendarDateTime, DatePerhapsTime};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub enum EventTime {
7    Date(NaiveDate),
8    DateTimeUtc(DateTime<Utc>),
9    DateTimeFloating(NaiveDateTime),
10    DateTimeZoned {
11        datetime: NaiveDateTime,
12        tzid: String,
13    },
14}
15
16/// Comparable form of `EventTime`
17/// with resolvable zones converted to UTC.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub(crate) enum NormalizedEventTime {
20    Date(NaiveDate),
21    Instant(DateTime<Utc>),
22    Floating(NaiveDateTime),
23}
24
25impl EventTime {
26    pub fn to_local_tz<Tz: TimeZone>(&self, tz: &Tz) -> DateTime<Tz> {
27        match self {
28            EventTime::Date(date) => resolve_local(
29                date.and_hms_opt(0, 0, 0)
30                    .expect("midnight should be a valid NaiveDateTime"),
31                tz,
32            ),
33            EventTime::DateTimeFloating(datetime) => resolve_local(*datetime, tz),
34            EventTime::DateTimeUtc(datetime) => datetime.with_timezone(tz),
35            EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
36                Some(event_tz) => resolve_local(*datetime, &event_tz).with_timezone(tz),
37                None => resolve_local(*datetime, tz),
38            },
39        }
40    }
41
42    pub fn to_utc(&self) -> DateTime<Utc> {
43        self.to_local_tz(&chrono::Local).with_timezone(&Utc)
44    }
45
46    /// Check if this is an all-day date (not a datetime)
47    pub fn is_date(&self) -> bool {
48        matches!(self, EventTime::Date(_))
49    }
50
51    pub(crate) fn normalized(&self) -> NormalizedEventTime {
52        match self {
53            EventTime::Date(date) => NormalizedEventTime::Date(*date),
54            EventTime::DateTimeUtc(datetime) => NormalizedEventTime::Instant(*datetime),
55            EventTime::DateTimeFloating(datetime) => NormalizedEventTime::Floating(*datetime),
56            EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
57                Some(tz) => {
58                    NormalizedEventTime::Instant(resolve_local(*datetime, &tz).with_timezone(&Utc))
59                }
60                None => NormalizedEventTime::Floating(*datetime),
61            },
62        }
63    }
64}
65
66fn parse_tzid(tzid: &str) -> Option<chrono_tz::Tz> {
67    match tzid.parse() {
68        Ok(tz) => Some(tz),
69        Err(_) => {
70            tz_normalize::warn_unknown(tzid);
71            None
72        }
73    }
74}
75
76/// Convert a naive (non-zoned) datetime into a zoned datetime.
77/// Picks a deterministic answer when DST makes the wall clock ambiguous
78/// (e.g. Stockholm 02:30 on 2026-10-25, which happens twice because clocks roll back)
79fn resolve_local<Tz: TimeZone>(naive: NaiveDateTime, tz: &Tz) -> DateTime<Tz> {
80    if let Some(dt) = tz.from_local_datetime(&naive).earliest() {
81        return dt;
82    }
83    // DST gap (e.g. Stockholm 02:30 on 2026-03-29): skip forward past the jump.
84    let bumped = naive + chrono::Duration::hours(1);
85    tz.from_local_datetime(&bumped)
86        .earliest()
87        .unwrap_or_else(|| tz.from_utc_datetime(&naive))
88}
89
90impl From<&EventTime> for DatePerhapsTime {
91    fn from(value: &EventTime) -> Self {
92        match value {
93            EventTime::Date(date) => (*date).into(),
94            EventTime::DateTimeUtc(datetime) => (*datetime).into(),
95            EventTime::DateTimeFloating(datetime) => (*datetime).into(),
96            EventTime::DateTimeZoned { datetime, tzid } => CalendarDateTime::WithTimezone {
97                date_time: *datetime,
98                tzid: tzid.to_string(),
99            }
100            .into(),
101        }
102    }
103}
104
105impl From<EventTime> for DatePerhapsTime {
106    fn from(value: EventTime) -> Self {
107        DatePerhapsTime::from(&value)
108    }
109}
110
111impl From<DatePerhapsTime> for EventTime {
112    fn from(value: DatePerhapsTime) -> Self {
113        match value {
114            DatePerhapsTime::Date(date) => EventTime::Date(date),
115            DatePerhapsTime::DateTime(CalendarDateTime::Floating(datetime)) => {
116                EventTime::DateTimeFloating(datetime)
117            }
118            DatePerhapsTime::DateTime(CalendarDateTime::Utc(datetime)) => {
119                EventTime::DateTimeUtc(datetime)
120            }
121            DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone { date_time, tzid }) => {
122                match tz_normalize::classify(&tzid) {
123                    Tzid::Iana(tzid) => EventTime::DateTimeZoned {
124                        datetime: date_time,
125                        tzid,
126                    },
127                    Tzid::FixedOffset(seconds) => {
128                        let offset =
129                            FixedOffset::east_opt(seconds).expect("validated fixed offset");
130                        let datetime = offset
131                            .from_local_datetime(&date_time)
132                            .single()
133                            .expect("fixed offsets have no ambiguous local times")
134                            .with_timezone(&Utc);
135                        EventTime::DateTimeUtc(datetime)
136                    }
137                    Tzid::Unknown => {
138                        tz_normalize::warn_unknown(&tzid);
139                        EventTime::DateTimeZoned {
140                            datetime: date_time,
141                            tzid,
142                        }
143                    }
144                }
145            }
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn to_local_converts_utc_event_to_target_timezone() {
156        // 2024-01-01 12:00:00 UTC
157        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
158
159        let event_time = &EventTime::DateTimeUtc(utc);
160
161        // User is in Stockholm:
162        let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
163
164        // Local time should be CET (UTC+1) in January = 13:00
165        assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1300");
166    }
167
168    #[test]
169    fn to_local_converts_zoned_event_to_target_timezone() {
170        let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
171            .unwrap()
172            .and_hms_opt(12, 0, 0)
173            .unwrap();
174
175        // Even creator is in NYC:
176        let event_time = &EventTime::DateTimeZoned {
177            datetime: date_time,
178            tzid: "America/New_York".to_string(),
179        };
180
181        // User is in Stockholm:
182        let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
183
184        // 12:00 in NY (EST, UTC-5)
185        // = 18:00 in Stockholm (CET, UTC+1) in January
186        assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1800");
187    }
188
189    #[test]
190    fn to_local_treats_unknown_zoned_event_as_floating() {
191        let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
192            .unwrap()
193            .and_hms_opt(12, 0, 0)
194            .unwrap();
195        let event_time = EventTime::DateTimeZoned {
196            datetime: date_time,
197            tzid: "Bogus/Zone".to_string(),
198        };
199
200        let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
201
202        assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1200");
203    }
204
205    #[test]
206    fn unknown_zoned_event_matches_floating_semantics() {
207        let datetime = NaiveDate::from_ymd_opt(2024, 1, 1)
208            .unwrap()
209            .and_hms_opt(12, 0, 0)
210            .unwrap();
211        let zoned = EventTime::DateTimeZoned {
212            datetime,
213            tzid: "Bogus/Zone".to_string(),
214        };
215        let floating = EventTime::DateTimeFloating(datetime);
216
217        assert_eq!(
218            zoned.to_local_tz(&chrono_tz::Europe::Stockholm),
219            floating.to_local_tz(&chrono_tz::Europe::Stockholm)
220        );
221        assert_eq!(zoned.to_utc(), floating.to_utc());
222        assert_eq!(zoned.normalized(), NormalizedEventTime::Floating(datetime));
223    }
224
225    #[test]
226    fn to_local_handles_dst_spring_forward_gap() {
227        // In Stockholm, 2024-03-31 02:30 doesn't exist — clocks jumped from
228        // 02:00 CET to 03:00 CEST. We should still produce a slug.
229        let naive = NaiveDate::from_ymd_opt(2024, 3, 31)
230            .unwrap()
231            .and_hms_opt(2, 30, 0)
232            .unwrap();
233
234        let event_time = EventTime::DateTimeFloating(naive);
235
236        let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
237
238        // Fallback skips forward past the jump, landing at 03:30 CEST.
239        assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-03-31T0330");
240    }
241
242    #[test]
243    fn to_local_handles_dst_fall_back_overlap() {
244        // In Stockholm, 2024-10-27 02:30 happens twice — clocks rewound from
245        // 03:00 CEST back to 02:00 CET. We pick the earliest occurrence.
246        let naive = NaiveDate::from_ymd_opt(2024, 10, 27)
247            .unwrap()
248            .and_hms_opt(2, 30, 0)
249            .unwrap();
250
251        let event_time = EventTime::DateTimeFloating(naive);
252
253        let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
254
255        assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-10-27T0230");
256    }
257
258    #[test]
259    fn fractional_offset_tzid_parses_to_utc() {
260        let datetime = NaiveDate::from_ymd_opt(2026, 7, 24)
261            .unwrap()
262            .and_hms_opt(19, 2, 0)
263            .unwrap();
264        let parsed = EventTime::from(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
265            date_time: datetime,
266            tzid: "GMT+0530".to_string(),
267        }));
268
269        // 19:02 at +05:30 = 13:32 UTC
270        assert_eq!(
271            parsed,
272            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 7, 24, 13, 32, 0).unwrap())
273        );
274    }
275
276    #[test]
277    fn windows_display_names_preserve_dst_rules() {
278        for (tzid, date, expected_tzid, expected_utc) in [
279            (
280                "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
281                (2026, 1, 15),
282                "Europe/Berlin",
283                (2026, 1, 15, 14),
284            ),
285            (
286                "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
287                (2026, 7, 15),
288                "Europe/Berlin",
289                (2026, 7, 15, 13),
290            ),
291            (
292                "(UTC-05:00) Eastern Time (US & Canada)",
293                (2026, 1, 15),
294                "America/New_York",
295                (2026, 1, 15, 20),
296            ),
297            (
298                "(UTC-05:00) Eastern Time (US & Canada)",
299                (2026, 7, 15),
300                "America/New_York",
301                (2026, 7, 15, 19),
302            ),
303        ] {
304            let datetime = NaiveDate::from_ymd_opt(date.0, date.1, date.2)
305                .unwrap()
306                .and_hms_opt(15, 0, 0)
307                .unwrap();
308            let parsed =
309                EventTime::from(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
310                    date_time: datetime,
311                    tzid: tzid.to_string(),
312                }));
313
314            assert!(
315                matches!(&parsed, EventTime::DateTimeZoned { tzid, .. } if tzid == expected_tzid),
316                "{tzid} on {datetime} normalized incorrectly"
317            );
318            assert_eq!(
319                parsed.to_utc(),
320                Utc.with_ymd_and_hms(
321                    expected_utc.0,
322                    expected_utc.1,
323                    expected_utc.2,
324                    expected_utc.3,
325                    0,
326                    0,
327                )
328                .unwrap(),
329                "{tzid} on {datetime} resolved incorrectly"
330            );
331        }
332    }
333
334    #[test]
335    fn to_utc_passes_through_utc_event() {
336        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
337
338        let event_time = EventTime::DateTimeUtc(utc);
339
340        assert_eq!(event_time.to_utc(), utc);
341    }
342
343    #[test]
344    fn to_utc_converts_zoned_event_using_tzid() {
345        // 12:00 in NYC (EST, UTC-5) in January = 17:00 UTC
346        let datetime = NaiveDate::from_ymd_opt(2024, 1, 1)
347            .unwrap()
348            .and_hms_opt(12, 0, 0)
349            .unwrap();
350        let event_time = EventTime::DateTimeZoned {
351            datetime,
352            tzid: "America/New_York".to_string(),
353        };
354
355        let utc = event_time.to_utc();
356
357        assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1700");
358    }
359
360    #[test]
361    fn to_utc_respects_dst_for_zoned_event() {
362        // 12:00 in NYC during DST (EDT, UTC-4) in July = 16:00 UTC
363        let datetime = NaiveDate::from_ymd_opt(2024, 7, 1)
364            .unwrap()
365            .and_hms_opt(12, 0, 0)
366            .unwrap();
367        let event_time = EventTime::DateTimeZoned {
368            datetime,
369            tzid: "America/New_York".to_string(),
370        };
371
372        let utc = event_time.to_utc();
373
374        assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-07-01T1600");
375    }
376
377    #[test]
378    fn to_utc_handles_dst_gap_and_overlap() {
379        for (datetime, expected) in [
380            ((2026, 3, 29, 1, 30), (2026, 3, 29, 1, 30)),
381            ((2026, 10, 25, 1, 30), (2026, 10, 25, 0, 30)),
382        ] {
383            let event_time = EventTime::DateTimeZoned {
384                datetime: NaiveDate::from_ymd_opt(datetime.0, datetime.1, datetime.2)
385                    .unwrap()
386                    .and_hms_opt(datetime.3, datetime.4, 0)
387                    .unwrap(),
388                tzid: "Europe/London".to_string(),
389            };
390
391            assert_eq!(
392                event_time.to_utc(),
393                Utc.with_ymd_and_hms(
394                    expected.0, expected.1, expected.2, expected.3, expected.4, 0,
395                )
396                .unwrap()
397            );
398        }
399    }
400}