Skip to main content

grafeo_common/types/
zoned_datetime.rs

1//! Zoned datetime type: an instant in time with a fixed UTC offset.
2//!
3//! [`ZonedDatetime`] pairs a UTC microsecond timestamp with a numeric
4//! offset (in seconds). Two values representing the same instant are
5//! equal regardless of offset.
6
7use serde::{Deserialize, Serialize};
8use std::cmp::Ordering;
9use std::fmt;
10use std::hash::{Hash, Hasher};
11
12use super::{Date, Time, Timestamp};
13
14/// A datetime with a fixed UTC offset.
15///
16/// Internally stores the instant as microseconds since the Unix epoch
17/// (same as [`Timestamp`]) plus an offset in seconds from UTC. Two
18/// `ZonedDatetime` values are equal when they refer to the same instant,
19/// even if their offsets differ.
20///
21/// # Examples
22///
23/// ```
24/// use grafeo_common::types::ZonedDatetime;
25///
26/// let zdt = ZonedDatetime::parse("2024-06-15T10:30:00+05:30").unwrap();
27/// assert_eq!(zdt.offset_seconds(), 19800);
28/// assert_eq!(zdt.to_string(), "2024-06-15T10:30:00+05:30");
29/// ```
30#[derive(Clone, Copy, Serialize, Deserialize)]
31pub struct ZonedDatetime {
32    /// Microseconds since Unix epoch (UTC).
33    utc_micros: i64,
34    /// Fixed offset from UTC in seconds (e.g., +19800 for +05:30).
35    offset_seconds: i32,
36}
37
38impl ZonedDatetime {
39    /// Creates a `ZonedDatetime` from a UTC timestamp and an offset in seconds.
40    #[inline]
41    #[must_use]
42    pub const fn from_timestamp_offset(ts: Timestamp, offset_seconds: i32) -> Self {
43        Self {
44            utc_micros: ts.as_micros(),
45            offset_seconds,
46        }
47    }
48
49    /// Creates a `ZonedDatetime` from a date and a time with a required offset.
50    ///
51    /// Returns `None` if the time has no offset.
52    #[must_use]
53    pub fn from_date_time(date: Date, time: Time) -> Option<Self> {
54        let offset = time.offset_seconds()?;
55        let ts = Timestamp::from_date_time(date, time);
56        Some(Self {
57            utc_micros: ts.as_micros(),
58            offset_seconds: offset,
59        })
60    }
61
62    /// Returns the underlying UTC timestamp.
63    #[inline]
64    #[must_use]
65    pub const fn as_timestamp(&self) -> Timestamp {
66        Timestamp::from_micros(self.utc_micros)
67    }
68
69    /// Returns the UTC offset in seconds.
70    #[inline]
71    #[must_use]
72    pub const fn offset_seconds(&self) -> i32 {
73        self.offset_seconds
74    }
75
76    /// Returns the date in the local (offset-adjusted) timezone.
77    #[must_use]
78    pub fn to_local_date(&self) -> Date {
79        let local_micros = self.utc_micros + self.offset_seconds as i64 * 1_000_000;
80        Timestamp::from_micros(local_micros).to_date()
81    }
82
83    /// Returns the time-of-day in the local (offset-adjusted) timezone,
84    /// carrying the offset.
85    #[must_use]
86    pub fn to_local_time(&self) -> Time {
87        let local_micros = self.utc_micros + self.offset_seconds as i64 * 1_000_000;
88        Timestamp::from_micros(local_micros)
89            .to_time()
90            .with_offset(self.offset_seconds)
91    }
92
93    /// Truncates this zoned datetime to the given unit, preserving the offset.
94    ///
95    /// Truncation is performed on the local (offset-adjusted) time, then
96    /// converted back to UTC. This ensures that truncating to "day" gives
97    /// midnight in the local timezone, not midnight UTC.
98    #[must_use]
99    pub fn truncate(&self, unit: &str) -> Option<Self> {
100        // Convert to local, truncate, convert back
101        let local_micros = self.utc_micros + self.offset_seconds as i64 * 1_000_000;
102        let local_ts = Timestamp::from_micros(local_micros);
103        let truncated_local = local_ts.truncate(unit)?;
104        let utc_micros = truncated_local.as_micros() - self.offset_seconds as i64 * 1_000_000;
105        Some(Self {
106            utc_micros,
107            offset_seconds: self.offset_seconds,
108        })
109    }
110
111    /// Parses an ISO 8601 datetime string with a mandatory UTC offset.
112    ///
113    /// Accepted formats:
114    /// - `YYYY-MM-DDTHH:MM:SS+HH:MM`
115    /// - `YYYY-MM-DDTHH:MM:SS.fff+HH:MM`
116    /// - `YYYY-MM-DDTHH:MM:SSZ`
117    ///
118    /// Returns `None` if the string is not valid or has no offset.
119    #[must_use]
120    pub fn parse(s: &str) -> Option<Self> {
121        let pos = s.find('T').or_else(|| s.find('t'))?;
122        let date = Date::parse(&s[..pos])?;
123        let time = Time::parse(&s[pos + 1..])?;
124        // Require an explicit offset
125        time.offset_seconds()?;
126        Self::from_date_time(date, time)
127    }
128}
129
130impl PartialEq for ZonedDatetime {
131    fn eq(&self, other: &Self) -> bool {
132        self.utc_micros == other.utc_micros
133    }
134}
135
136impl Eq for ZonedDatetime {}
137
138impl Hash for ZonedDatetime {
139    fn hash<H: Hasher>(&self, state: &mut H) {
140        // Hash by UTC instant only, consistent with PartialEq
141        self.utc_micros.hash(state);
142    }
143}
144
145impl Ord for ZonedDatetime {
146    fn cmp(&self, other: &Self) -> Ordering {
147        self.utc_micros.cmp(&other.utc_micros)
148    }
149}
150
151impl PartialOrd for ZonedDatetime {
152    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
153        Some(self.cmp(other))
154    }
155}
156
157impl fmt::Debug for ZonedDatetime {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(f, "ZonedDatetime({})", self)
160    }
161}
162
163impl fmt::Display for ZonedDatetime {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        let local_micros = self.utc_micros + self.offset_seconds as i64 * 1_000_000;
166        let ts = Timestamp::from_micros(local_micros);
167        let date = ts.to_date();
168        let time = ts.to_time();
169
170        let (year, month, day) = (date.year(), date.month(), date.day());
171        let (h, m, s) = (time.hour(), time.minute(), time.second());
172        let micro_frac = local_micros.rem_euclid(1_000_000) as u64;
173
174        if micro_frac > 0 {
175            // Trim trailing zeros from fractional seconds
176            let frac = format!("{micro_frac:06}");
177            let trimmed = frac.trim_end_matches('0');
178            write!(
179                f,
180                "{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{trimmed}"
181            )?;
182        } else {
183            write!(f, "{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}")?;
184        }
185
186        match self.offset_seconds {
187            0 => write!(f, "Z"),
188            off => {
189                let sign = if off >= 0 { '+' } else { '-' };
190                let abs = off.unsigned_abs();
191                let oh = abs / 3600;
192                let om = (abs % 3600) / 60;
193                write!(f, "{sign}{oh:02}:{om:02}")
194            }
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_parse_utc() {
205        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00Z").unwrap();
206        assert_eq!(zdt.offset_seconds(), 0);
207        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00Z");
208    }
209
210    #[test]
211    fn test_parse_positive_offset() {
212        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00+05:30").unwrap();
213        assert_eq!(zdt.offset_seconds(), 19800);
214        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00+05:30");
215    }
216
217    #[test]
218    fn test_parse_negative_offset() {
219        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00-04:00").unwrap();
220        assert_eq!(zdt.offset_seconds(), -14400);
221        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00-04:00");
222    }
223
224    #[test]
225    fn test_equality_same_instant() {
226        // Same instant, different offsets: should be equal
227        let z1 = ZonedDatetime::parse("2024-06-15T15:30:00+05:30").unwrap();
228        let z2 = ZonedDatetime::parse("2024-06-15T10:00:00Z").unwrap();
229        assert_eq!(z1, z2);
230    }
231
232    #[test]
233    fn test_no_offset_fails() {
234        assert!(ZonedDatetime::parse("2024-06-15T10:30:00").is_none());
235    }
236
237    #[test]
238    fn test_local_date_time() {
239        let zdt = ZonedDatetime::parse("2024-06-15T23:30:00+05:30").unwrap();
240        let local_date = zdt.to_local_date();
241        assert_eq!(local_date.year(), 2024);
242        assert_eq!(local_date.month(), 6);
243        assert_eq!(local_date.day(), 15);
244
245        let local_time = zdt.to_local_time();
246        assert_eq!(local_time.hour(), 23);
247        assert_eq!(local_time.minute(), 30);
248        assert_eq!(local_time.offset_seconds(), Some(19800));
249    }
250
251    #[test]
252    fn test_from_timestamp_offset() {
253        let ts = Timestamp::from_secs(1_718_444_400); // 2024-06-15T09:40:00Z
254        let zdt = ZonedDatetime::from_timestamp_offset(ts, 3600); // +01:00
255        assert_eq!(zdt.as_timestamp(), ts);
256        assert_eq!(zdt.offset_seconds(), 3600);
257        assert_eq!(zdt.to_string(), "2024-06-15T10:40:00+01:00");
258    }
259
260    #[test]
261    fn test_ordering() {
262        let earlier = ZonedDatetime::parse("2024-06-15T10:00:00Z").unwrap();
263        let later = ZonedDatetime::parse("2024-06-15T12:00:00Z").unwrap();
264        assert!(earlier < later);
265    }
266
267    #[test]
268    fn test_truncate() {
269        // 2024-06-15T14:30:45+02:00 (Amsterdam summer time)
270        let zdt = ZonedDatetime::parse("2024-06-15T14:30:45+02:00").unwrap();
271
272        let day = zdt.truncate("day").unwrap();
273        assert_eq!(day.offset_seconds(), 7200);
274        // Truncated to midnight local time
275        assert_eq!(day.to_string(), "2024-06-15T00:00:00+02:00");
276
277        let hour = zdt.truncate("hour").unwrap();
278        assert_eq!(hour.to_string(), "2024-06-15T14:00:00+02:00");
279
280        let minute = zdt.truncate("minute").unwrap();
281        assert_eq!(minute.to_string(), "2024-06-15T14:30:00+02:00");
282    }
283
284    #[test]
285    fn test_with_fractional_seconds() {
286        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00.123Z").unwrap();
287        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00.123Z");
288    }
289}