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    /// Parses an ISO 8601 datetime string with a mandatory UTC offset.
94    ///
95    /// Accepted formats:
96    /// - `YYYY-MM-DDTHH:MM:SS+HH:MM`
97    /// - `YYYY-MM-DDTHH:MM:SS.fff+HH:MM`
98    /// - `YYYY-MM-DDTHH:MM:SSZ`
99    ///
100    /// Returns `None` if the string is not valid or has no offset.
101    #[must_use]
102    pub fn parse(s: &str) -> Option<Self> {
103        let pos = s.find('T').or_else(|| s.find('t'))?;
104        let date = Date::parse(&s[..pos])?;
105        let time = Time::parse(&s[pos + 1..])?;
106        // Require an explicit offset
107        time.offset_seconds()?;
108        Self::from_date_time(date, time)
109    }
110}
111
112impl PartialEq for ZonedDatetime {
113    fn eq(&self, other: &Self) -> bool {
114        self.utc_micros == other.utc_micros
115    }
116}
117
118impl Eq for ZonedDatetime {}
119
120impl Hash for ZonedDatetime {
121    fn hash<H: Hasher>(&self, state: &mut H) {
122        // Hash by UTC instant only, consistent with PartialEq
123        self.utc_micros.hash(state);
124    }
125}
126
127impl Ord for ZonedDatetime {
128    fn cmp(&self, other: &Self) -> Ordering {
129        self.utc_micros.cmp(&other.utc_micros)
130    }
131}
132
133impl PartialOrd for ZonedDatetime {
134    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
135        Some(self.cmp(other))
136    }
137}
138
139impl fmt::Debug for ZonedDatetime {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        write!(f, "ZonedDatetime({})", self)
142    }
143}
144
145impl fmt::Display for ZonedDatetime {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        let local_micros = self.utc_micros + self.offset_seconds as i64 * 1_000_000;
148        let ts = Timestamp::from_micros(local_micros);
149        let date = ts.to_date();
150        let time = ts.to_time();
151
152        let (year, month, day) = (date.year(), date.month(), date.day());
153        let (h, m, s) = (time.hour(), time.minute(), time.second());
154        let micro_frac = local_micros.rem_euclid(1_000_000) as u64;
155
156        if micro_frac > 0 {
157            // Trim trailing zeros from fractional seconds
158            let frac = format!("{micro_frac:06}");
159            let trimmed = frac.trim_end_matches('0');
160            write!(
161                f,
162                "{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{trimmed}"
163            )?;
164        } else {
165            write!(f, "{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}")?;
166        }
167
168        match self.offset_seconds {
169            0 => write!(f, "Z"),
170            off => {
171                let sign = if off >= 0 { '+' } else { '-' };
172                let abs = off.unsigned_abs();
173                let oh = abs / 3600;
174                let om = (abs % 3600) / 60;
175                write!(f, "{sign}{oh:02}:{om:02}")
176            }
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_parse_utc() {
187        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00Z").unwrap();
188        assert_eq!(zdt.offset_seconds(), 0);
189        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00Z");
190    }
191
192    #[test]
193    fn test_parse_positive_offset() {
194        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00+05:30").unwrap();
195        assert_eq!(zdt.offset_seconds(), 19800);
196        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00+05:30");
197    }
198
199    #[test]
200    fn test_parse_negative_offset() {
201        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00-04:00").unwrap();
202        assert_eq!(zdt.offset_seconds(), -14400);
203        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00-04:00");
204    }
205
206    #[test]
207    fn test_equality_same_instant() {
208        // Same instant, different offsets: should be equal
209        let z1 = ZonedDatetime::parse("2024-06-15T15:30:00+05:30").unwrap();
210        let z2 = ZonedDatetime::parse("2024-06-15T10:00:00Z").unwrap();
211        assert_eq!(z1, z2);
212    }
213
214    #[test]
215    fn test_no_offset_fails() {
216        assert!(ZonedDatetime::parse("2024-06-15T10:30:00").is_none());
217    }
218
219    #[test]
220    fn test_local_date_time() {
221        let zdt = ZonedDatetime::parse("2024-06-15T23:30:00+05:30").unwrap();
222        let local_date = zdt.to_local_date();
223        assert_eq!(local_date.year(), 2024);
224        assert_eq!(local_date.month(), 6);
225        assert_eq!(local_date.day(), 15);
226
227        let local_time = zdt.to_local_time();
228        assert_eq!(local_time.hour(), 23);
229        assert_eq!(local_time.minute(), 30);
230        assert_eq!(local_time.offset_seconds(), Some(19800));
231    }
232
233    #[test]
234    fn test_from_timestamp_offset() {
235        let ts = Timestamp::from_secs(1_718_444_400); // 2024-06-15T09:40:00Z
236        let zdt = ZonedDatetime::from_timestamp_offset(ts, 3600); // +01:00
237        assert_eq!(zdt.as_timestamp(), ts);
238        assert_eq!(zdt.offset_seconds(), 3600);
239        assert_eq!(zdt.to_string(), "2024-06-15T10:40:00+01:00");
240    }
241
242    #[test]
243    fn test_ordering() {
244        let earlier = ZonedDatetime::parse("2024-06-15T10:00:00Z").unwrap();
245        let later = ZonedDatetime::parse("2024-06-15T12:00:00Z").unwrap();
246        assert!(earlier < later);
247    }
248
249    #[test]
250    fn test_with_fractional_seconds() {
251        let zdt = ZonedDatetime::parse("2024-06-15T10:30:00.123Z").unwrap();
252        assert_eq!(zdt.to_string(), "2024-06-15T10:30:00.123Z");
253    }
254}