Skip to main content

nodedb_types/
datetime.rs

1//! First-class DateTime and Duration types.
2//!
3//! `NdbDateTime` stores microseconds since Unix epoch (1970-01-01T00:00:00Z).
4//! `NdbDuration` stores microseconds as a signed i64.
5//!
6//! Both serialize as strings (ISO 8601 for DateTime, human-readable for Duration)
7//! for JSON compatibility. Internal representation is i64 for efficient comparison
8//! and arithmetic.
9
10use serde::{Deserialize, Serialize};
11
12/// Microseconds-precision UTC timestamp.
13///
14/// Stores microseconds since Unix epoch as i64. Supports dates from
15/// ~292,000 years BCE to ~292,000 years CE.
16///
17/// String format: ISO 8601 `"2024-03-15T10:30:00.000000Z"`.
18#[derive(
19    Debug,
20    Clone,
21    Copy,
22    PartialEq,
23    Eq,
24    PartialOrd,
25    Ord,
26    Hash,
27    Serialize,
28    Deserialize,
29    zerompk::ToMessagePack,
30    zerompk::FromMessagePack,
31)]
32pub struct NdbDateTime {
33    /// Microseconds since Unix epoch (1970-01-01T00:00:00Z).
34    pub micros: i64,
35}
36
37impl NdbDateTime {
38    /// Create from microseconds since epoch.
39    pub fn from_micros(micros: i64) -> Self {
40        Self { micros }
41    }
42
43    /// Create from milliseconds since epoch.
44    pub fn from_millis(millis: i64) -> Self {
45        Self {
46            micros: millis * 1000,
47        }
48    }
49
50    /// Create from seconds since epoch.
51    pub fn from_secs(secs: i64) -> Self {
52        Self {
53            micros: secs * 1_000_000,
54        }
55    }
56
57    /// Current UTC time.
58    pub fn now() -> Self {
59        let dur = std::time::SystemTime::now()
60            .duration_since(std::time::UNIX_EPOCH)
61            .unwrap_or_default();
62        Self {
63            micros: dur.as_micros() as i64,
64        }
65    }
66
67    /// Extract year, month, day, hour, minute, second components.
68    pub fn components(&self) -> DateTimeComponents {
69        let total_secs = self.micros / 1_000_000;
70        let micros_rem = (self.micros % 1_000_000).unsigned_abs();
71
72        // Civil date from Unix timestamp (algorithm from Howard Hinnant).
73        let mut days = total_secs.div_euclid(86400) as i32;
74        let day_secs = total_secs.rem_euclid(86400) as u32;
75
76        days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
77        let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
78        let doe = (days - era * 146_097) as u32;
79        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
80        let y = yoe as i32 + era * 400;
81        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
82        let mp = (5 * doy + 2) / 153;
83        let d = doy - (153 * mp + 2) / 5 + 1;
84        let m = if mp < 10 { mp + 3 } else { mp - 9 };
85        let year = if m <= 2 { y + 1 } else { y };
86
87        DateTimeComponents {
88            year,
89            month: m as u8,
90            day: d as u8,
91            hour: (day_secs / 3600) as u8,
92            minute: ((day_secs % 3600) / 60) as u8,
93            second: (day_secs % 60) as u8,
94            microsecond: micros_rem as u32,
95        }
96    }
97
98    /// Format as ISO 8601 string: `"2024-03-15T10:30:00.000000Z"`.
99    pub fn to_iso8601(&self) -> String {
100        let c = self.components();
101        format!(
102            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
103            c.year, c.month, c.day, c.hour, c.minute, c.second, c.microsecond
104        )
105    }
106
107    /// Parse from ISO 8601 string (basic subset).
108    ///
109    /// Supports: `"2024-03-15T10:30:00Z"`, `"2024-03-15T10:30:00.123456Z"`,
110    /// `"2024-03-15"` (midnight UTC).
111    pub fn parse(s: &str) -> Option<Self> {
112        let s = s.trim().trim_end_matches('Z').trim_end_matches('z');
113
114        if s.len() == 10 {
115            // Date only: "2024-03-15" → midnight UTC.
116            let parts: Vec<&str> = s.split('-').collect();
117            if parts.len() != 3 {
118                return None;
119            }
120            let year: i32 = parts[0].parse().ok()?;
121            let month: u32 = parts[1].parse().ok()?;
122            let day: u32 = parts[2].parse().ok()?;
123            return Some(Self::from_civil(year, month, day, 0, 0, 0, 0));
124        }
125
126        // Full: "2024-03-15T10:30:00" or "2024-03-15T10:30:00.123456"
127        let (date_part, time_part) = s.split_once('T').or_else(|| s.split_once(' '))?;
128        let date_parts: Vec<&str> = date_part.split('-').collect();
129        if date_parts.len() != 3 {
130            return None;
131        }
132        let year: i32 = date_parts[0].parse().ok()?;
133        let month: u32 = date_parts[1].parse().ok()?;
134        let day: u32 = date_parts[2].parse().ok()?;
135
136        let (time_main, frac) = if let Some((t, f)) = time_part.split_once('.') {
137            (t, f)
138        } else {
139            (time_part, "0")
140        };
141        let time_parts: Vec<&str> = time_main.split(':').collect();
142        if time_parts.len() < 2 {
143            return None;
144        }
145        let hour: u32 = time_parts[0].parse().ok()?;
146        let minute: u32 = time_parts[1].parse().ok()?;
147        let second: u32 = time_parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
148
149        // Parse fractional seconds (up to microseconds).
150        let frac_padded = format!("{frac:0<6}");
151        let micros: u32 = frac_padded[..6].parse().unwrap_or(0);
152
153        Some(Self::from_civil(
154            year, month, day, hour, minute, second, micros,
155        ))
156    }
157
158    /// Build from civil date components.
159    fn from_civil(
160        year: i32,
161        month: u32,
162        day: u32,
163        hour: u32,
164        minute: u32,
165        second: u32,
166        micros: u32,
167    ) -> Self {
168        // Inverse of the Hinnant algorithm.
169        let y = if month <= 2 { year - 1 } else { year };
170        let m = if month <= 2 { month + 9 } else { month - 3 };
171        let era = if y >= 0 { y } else { y - 399 } / 400;
172        let yoe = (y - era * 400) as u32;
173        let doy = (153 * m + 2) / 5 + day - 1;
174        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
175        let days = era as i64 * 146_097 + doe as i64 - 719_468;
176        let total_secs = days * 86400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64;
177        Self {
178            micros: total_secs * 1_000_000 + micros as i64,
179        }
180    }
181
182    /// Add a duration.
183    pub fn add_duration(&self, d: NdbDuration) -> Self {
184        Self {
185            micros: self.micros + d.micros,
186        }
187    }
188
189    /// Subtract a duration.
190    pub fn sub_duration(&self, d: NdbDuration) -> Self {
191        Self {
192            micros: self.micros - d.micros,
193        }
194    }
195
196    /// Duration between two timestamps.
197    pub fn duration_since(&self, other: &NdbDateTime) -> NdbDuration {
198        NdbDuration {
199            micros: self.micros - other.micros,
200        }
201    }
202
203    /// Unix epoch seconds.
204    pub fn unix_secs(&self) -> i64 {
205        self.micros / 1_000_000
206    }
207
208    /// Unix epoch milliseconds.
209    pub fn unix_millis(&self) -> i64 {
210        self.micros / 1_000
211    }
212}
213
214impl std::fmt::Display for NdbDateTime {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.write_str(&self.to_iso8601())
217    }
218}
219
220/// Components of a civil date-time.
221#[derive(Debug, Clone, Copy)]
222pub struct DateTimeComponents {
223    pub year: i32,
224    pub month: u8,
225    pub day: u8,
226    pub hour: u8,
227    pub minute: u8,
228    pub second: u8,
229    pub microsecond: u32,
230}
231
232/// Microseconds-precision duration (signed).
233///
234/// String format: human-readable `"1h30m15s"` or `"500ms"`.
235#[derive(
236    Debug,
237    Clone,
238    Copy,
239    PartialEq,
240    Eq,
241    PartialOrd,
242    Ord,
243    Hash,
244    Serialize,
245    Deserialize,
246    zerompk::ToMessagePack,
247    zerompk::FromMessagePack,
248)]
249pub struct NdbDuration {
250    /// Microseconds (signed: negative = past).
251    pub micros: i64,
252}
253
254impl NdbDuration {
255    pub fn from_micros(micros: i64) -> Self {
256        Self { micros }
257    }
258
259    pub fn from_millis(millis: i64) -> Self {
260        Self {
261            micros: millis * 1_000,
262        }
263    }
264
265    pub fn from_secs(secs: i64) -> Self {
266        Self {
267            micros: secs * 1_000_000,
268        }
269    }
270
271    pub fn from_minutes(mins: i64) -> Self {
272        Self {
273            micros: mins * 60 * 1_000_000,
274        }
275    }
276
277    pub fn from_hours(hours: i64) -> Self {
278        Self {
279            micros: hours * 3600 * 1_000_000,
280        }
281    }
282
283    pub fn from_days(days: i64) -> Self {
284        Self {
285            micros: days * 86400 * 1_000_000,
286        }
287    }
288
289    pub fn as_secs_f64(&self) -> f64 {
290        self.micros as f64 / 1_000_000.0
291    }
292
293    pub fn as_millis(&self) -> i64 {
294        self.micros / 1_000
295    }
296
297    /// Format as human-readable string.
298    pub fn to_human(&self) -> String {
299        let abs = self.micros.unsigned_abs();
300        let sign = if self.micros < 0 { "-" } else { "" };
301
302        if abs < 1_000 {
303            return format!("{sign}{abs}us");
304        }
305        if abs < 1_000_000 {
306            return format!("{sign}{}ms", abs / 1_000);
307        }
308
309        let total_secs = abs / 1_000_000;
310        let hours = total_secs / 3600;
311        let mins = (total_secs % 3600) / 60;
312        let secs = total_secs % 60;
313
314        if hours > 0 {
315            if mins > 0 || secs > 0 {
316                format!("{sign}{hours}h{mins}m{secs}s")
317            } else {
318                format!("{sign}{hours}h")
319            }
320        } else if mins > 0 {
321            if secs > 0 {
322                format!("{sign}{mins}m{secs}s")
323            } else {
324                format!("{sign}{mins}m")
325            }
326        } else {
327            format!("{sign}{secs}s")
328        }
329    }
330
331    /// Parse from human-readable string: "1h30m", "500ms", "30s", "2d".
332    pub fn parse(s: &str) -> Option<Self> {
333        let s = s.trim();
334        if s.is_empty() {
335            return None;
336        }
337
338        let (neg, s) = if let Some(rest) = s.strip_prefix('-') {
339            (true, rest)
340        } else {
341            (false, s)
342        };
343
344        // Simple suffix parsing.
345        if let Some(n) = s.strip_suffix("us") {
346            let v: i64 = n.trim().parse().ok()?;
347            return Some(Self::from_micros(if neg { -v } else { v }));
348        }
349        if let Some(n) = s.strip_suffix("ms") {
350            let v: i64 = n.trim().parse().ok()?;
351            return Some(Self::from_millis(if neg { -v } else { v }));
352        }
353        if let Some(n) = s.strip_suffix('d') {
354            let v: i64 = n.trim().parse().ok()?;
355            return Some(Self::from_days(if neg { -v } else { v }));
356        }
357
358        // Compound: "1h30m15s"
359        let mut total_micros: i64 = 0;
360        let mut num_buf = String::new();
361        for c in s.chars() {
362            if c.is_ascii_digit() {
363                num_buf.push(c);
364            } else {
365                let n: i64 = num_buf.parse().ok()?;
366                num_buf.clear();
367                match c {
368                    'h' => total_micros += n * 3_600_000_000,
369                    'm' => total_micros += n * 60_000_000,
370                    's' => total_micros += n * 1_000_000,
371                    _ => return None,
372                }
373            }
374        }
375        // Trailing number without suffix = seconds.
376        if !num_buf.is_empty() {
377            let n: i64 = num_buf.parse().ok()?;
378            total_micros += n * 1_000_000;
379        }
380
381        if total_micros == 0 {
382            return None;
383        }
384
385        Some(Self::from_micros(if neg {
386            -total_micros
387        } else {
388            total_micros
389        }))
390    }
391}
392
393impl std::fmt::Display for NdbDuration {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.write_str(&self.to_human())
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn datetime_now_roundtrip() {
405        let dt = NdbDateTime::now();
406        let iso = dt.to_iso8601();
407        let parsed = NdbDateTime::parse(&iso).unwrap();
408        // Allow 1 microsecond rounding difference.
409        assert!(
410            (dt.micros - parsed.micros).abs() <= 1,
411            "dt={}, parsed={}",
412            dt.micros,
413            parsed.micros
414        );
415    }
416
417    #[test]
418    fn datetime_epoch() {
419        let dt = NdbDateTime::from_micros(0);
420        assert_eq!(dt.to_iso8601(), "1970-01-01T00:00:00.000000Z");
421    }
422
423    #[test]
424    fn datetime_known_date() {
425        let dt = NdbDateTime::parse("2024-03-15T10:30:00Z").unwrap();
426        let c = dt.components();
427        assert_eq!(c.year, 2024);
428        assert_eq!(c.month, 3);
429        assert_eq!(c.day, 15);
430        assert_eq!(c.hour, 10);
431        assert_eq!(c.minute, 30);
432        assert_eq!(c.second, 0);
433    }
434
435    #[test]
436    fn datetime_fractional_seconds() {
437        let dt = NdbDateTime::parse("2024-01-01T00:00:00.123456Z").unwrap();
438        let c = dt.components();
439        assert_eq!(c.microsecond, 123456);
440    }
441
442    #[test]
443    fn datetime_date_only() {
444        let dt = NdbDateTime::parse("2024-03-15").unwrap();
445        let c = dt.components();
446        assert_eq!(c.year, 2024);
447        assert_eq!(c.month, 3);
448        assert_eq!(c.day, 15);
449        assert_eq!(c.hour, 0);
450    }
451
452    #[test]
453    fn datetime_arithmetic() {
454        let dt = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
455        let later = dt.add_duration(NdbDuration::from_hours(24));
456        let c = later.components();
457        assert_eq!(c.day, 2);
458    }
459
460    #[test]
461    fn datetime_ordering() {
462        let a = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
463        let b = NdbDateTime::parse("2024-01-02T00:00:00Z").unwrap();
464        assert!(a < b);
465    }
466
467    #[test]
468    fn duration_human_format() {
469        assert_eq!(NdbDuration::from_secs(90).to_human(), "1m30s");
470        assert_eq!(NdbDuration::from_hours(2).to_human(), "2h");
471        assert_eq!(NdbDuration::from_millis(500).to_human(), "500ms");
472        assert_eq!(NdbDuration::from_micros(42).to_human(), "42us");
473        assert_eq!(NdbDuration::from_secs(3661).to_human(), "1h1m1s");
474    }
475
476    #[test]
477    fn duration_parse() {
478        assert_eq!(NdbDuration::parse("30s").unwrap().micros, 30_000_000);
479        assert_eq!(NdbDuration::parse("1h30m").unwrap().micros, 5_400_000_000);
480        assert_eq!(NdbDuration::parse("500ms").unwrap().micros, 500_000);
481        assert_eq!(NdbDuration::parse("2d").unwrap().micros, 172_800_000_000);
482        assert_eq!(NdbDuration::parse("-5s").unwrap().micros, -5_000_000);
483    }
484
485    #[test]
486    fn duration_roundtrip() {
487        let d = NdbDuration::from_secs(3661);
488        let s = d.to_human();
489        let parsed = NdbDuration::parse(&s).unwrap();
490        assert_eq!(d.micros, parsed.micros);
491    }
492
493    #[test]
494    fn unix_accessors() {
495        let dt = NdbDateTime::from_secs(1_700_000_000);
496        assert_eq!(dt.unix_secs(), 1_700_000_000);
497        assert_eq!(dt.unix_millis(), 1_700_000_000_000);
498    }
499}