Skip to main content

ppoppo_clock/
zoned.rs

1use crate::Tz;
2use time::{Duration, OffsetDateTime};
3
4/// A UTC instant paired with the timezone it should be displayed in.
5///
6/// The `instant` field always carries UTC offset +00:00 internally.
7/// `local_parts()` performs the TZ conversion on demand.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ZonedDateTime {
10    instant: OffsetDateTime,
11    tz: Tz,
12}
13
14/// Calendar / wall-clock components in a specific timezone.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct LocalParts {
17    pub year: i32,
18    pub month_1: u8,     // 1..=12
19    pub day: u8,         // 1..=31
20    pub hour: u8,        // 0..=23
21    pub minute: u8,      // 0..=59
22    pub second: u8,      // 0..=60 (leap second)
23    pub nano: u32,
24    pub dow_monday0: u8, // 0=Mon … 6=Sun
25}
26
27impl ZonedDateTime {
28    pub fn new(instant: OffsetDateTime, tz: Tz) -> Self {
29        // Normalise to UTC so all stored instants are comparable.
30        let utc = instant.to_offset(time::UtcOffset::UTC);
31        Self { instant: utc, tz }
32    }
33
34    pub fn instant(&self) -> OffsetDateTime {
35        self.instant
36    }
37
38    pub fn tz(&self) -> &Tz {
39        &self.tz
40    }
41
42    /// Returns a new `ZonedDateTime` shifted forward (or backward) by `dur`.
43    /// The timezone is preserved; only the instant changes.
44    pub fn add(&self, dur: Duration) -> Self {
45        Self {
46            instant: self.instant + dur,
47            tz: self.tz.clone(),
48        }
49    }
50
51    /// Extract wall-clock components in the stored timezone.
52    ///
53    /// On the `native` feature, this uses `time-tz` to apply the correct UTC
54    /// offset including DST. Without `native`, components are returned in UTC
55    /// (mock / WASM build contexts where tests don't exercise DST correctness).
56    pub fn local_parts(&self) -> LocalParts {
57        #[cfg(all(feature = "wasm", not(feature = "native")))]
58        {
59            let utc_ms = (self.instant.unix_timestamp_nanos() / 1_000_000) as f64;
60            if let Some(parts) = crate::wasm::intl_local_parts(utc_ms, self.tz.as_iana()) {
61                return parts;
62            }
63        }
64
65        let local = self.to_local_offset();
66        LocalParts {
67            year: local.year(),
68            month_1: local.month() as u8,
69            day: local.day(),
70            hour: local.hour(),
71            minute: local.minute(),
72            second: local.second(),
73            nano: local.nanosecond(),
74            dow_monday0: local.weekday().number_days_from_monday(),
75        }
76    }
77
78    #[cfg(feature = "native")]
79    fn to_local_offset(&self) -> OffsetDateTime {
80        use time_tz::OffsetDateTimeExt;
81        if let Some(tz) = time_tz::timezones::get_by_name(self.tz.as_iana()) {
82            self.instant.to_timezone(tz)
83        } else {
84            // Tz was parsed, so this should never happen — fall back to UTC.
85            self.instant
86        }
87    }
88
89    #[cfg(not(feature = "native"))]
90    fn to_local_offset(&self) -> OffsetDateTime {
91        // Without time-tz, return UTC. Tests requiring DST correctness need --features native.
92        self.instant
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use time::macros::datetime;
100
101    fn seoul() -> Tz {
102        Tz::parse("Asia/Seoul").unwrap_or_else(|_| Tz::seoul())
103    }
104
105    #[test]
106    fn instant_roundtrip_preserves_utc() {
107        let utc = datetime!(2026-05-10 00:00:00 UTC);
108        let zdt = ZonedDateTime::new(utc, Tz::utc());
109        assert_eq!(zdt.instant(), utc);
110    }
111
112    #[test]
113    fn add_shifts_instant_preserves_tz() {
114        let utc = datetime!(2026-05-10 00:00:00 UTC);
115        let tz = seoul();
116        let zdt = ZonedDateTime::new(utc, tz.clone());
117        let shifted = zdt.add(Duration::hours(15));
118        assert_eq!(shifted.instant(), utc + Duration::hours(15));
119        assert_eq!(shifted.tz(), &tz);
120    }
121
122    #[test]
123    fn local_parts_utc_midnight() {
124        let utc = datetime!(2026-05-10 00:00:00 UTC);
125        let zdt = ZonedDateTime::new(utc, Tz::utc());
126        let parts = zdt.local_parts();
127        assert_eq!(parts.hour, 0);
128        assert_eq!(parts.year, 2026);
129        assert_eq!(parts.month_1, 5);
130        assert_eq!(parts.day, 10);
131    }
132
133    #[cfg(feature = "native")]
134    #[test]
135    fn seoul_utc_midnight_is_hour_9() {
136        // UTC 2026-05-10 00:00 → KST 09:00 (UTC+9, no DST in Korea)
137        let utc = datetime!(2026-05-10 00:00:00 UTC);
138        let zdt = ZonedDateTime::new(utc, seoul());
139        assert_eq!(zdt.local_parts().hour, 9);
140    }
141
142    #[cfg(feature = "native")]
143    #[test]
144    fn kst_09_stored_as_utc_00() {
145        // KST 2026-05-10 09:00 = UTC 00:00 — instant stored as UTC
146        let utc = datetime!(2026-05-10 00:00:00 UTC);
147        let zdt = ZonedDateTime::new(utc, seoul());
148        assert_eq!(zdt.instant(), utc);
149        assert_eq!(zdt.local_parts().hour, 9);
150    }
151
152    #[cfg(feature = "native")]
153    #[test]
154    fn dst_transition_new_york_2024() {
155        // America/New_York spring-forward 2024-03-10 02:00 → 03:00
156        // 2024-03-10 06:59 UTC = 01:59 EST; 2024-03-10 07:00 UTC = 03:00 EDT
157        let tz = Tz::parse("America/New_York").expect("valid");
158        let before = datetime!(2024-03-10 06:59:00 UTC);
159        let after = datetime!(2024-03-10 07:00:00 UTC);
160        let zdt_before = ZonedDateTime::new(before, tz.clone());
161        let zdt_after = ZonedDateTime::new(after, tz);
162        assert_eq!(zdt_before.local_parts().hour, 1);
163        assert_eq!(zdt_after.local_parts().hour, 3);
164    }
165
166    #[test]
167    fn dow_monday0_sunday() {
168        // 2026-05-10 is a Sunday → dow_monday0 == 6
169        let utc = datetime!(2026-05-10 12:00:00 UTC);
170        let zdt = ZonedDateTime::new(utc, Tz::utc());
171        assert_eq!(zdt.local_parts().dow_monday0, 6);
172    }
173
174    #[test]
175    fn dow_monday0_monday() {
176        // 2026-05-11 is a Monday → dow_monday0 == 0
177        let utc = datetime!(2026-05-11 12:00:00 UTC);
178        let zdt = ZonedDateTime::new(utc, Tz::utc());
179        assert_eq!(zdt.local_parts().dow_monday0, 0);
180    }
181
182    #[cfg(feature = "native")]
183    mod proptest_zoned {
184        use super::*;
185        use proptest::prelude::*;
186        use time::OffsetDateTime;
187
188        proptest! {
189            #[test]
190            fn roundtrip_instant_preserved(
191                unix_secs in -2_000_000_000i64..2_000_000_000i64
192            ) {
193                let instant = OffsetDateTime::from_unix_timestamp(unix_secs)
194                    .unwrap_or(OffsetDateTime::UNIX_EPOCH);
195                let zdt = ZonedDateTime::new(instant, Tz::utc());
196                let recovered = zdt.instant();
197                prop_assert_eq!(recovered.unix_timestamp(), instant.unix_timestamp());
198            }
199        }
200    }
201}