Skip to main content

oxstack_net/
time.rs

1//! Unix time to calendar date/time (proleptic Gregorian, no time zones
2//! beyond a fixed UTC offset).
3
4/// A calendar date and time.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6#[cfg_attr(feature = "defmt", derive(defmt::Format))]
7pub struct DateTime {
8    /// Full year, e.g. 2026.
9    pub year: i32,
10    /// 1–12.
11    pub month: u8,
12    /// 1–31.
13    pub day: u8,
14    /// 0–23.
15    pub hour: u8,
16    /// 0–59.
17    pub minute: u8,
18    /// 0–59.
19    pub second: u8,
20    /// 0 = Monday … 6 = Sunday (ISO 8601).
21    pub weekday: u8,
22}
23
24impl DateTime {
25    /// Converts seconds since 1970-01-01T00:00:00 (shifted by
26    /// `offset_minutes` east of UTC) into a date and time.
27    pub const fn from_unix(unix_seconds: i64, offset_minutes: i32) -> Self {
28        let t = unix_seconds + offset_minutes as i64 * 60;
29        let days = t.div_euclid(86_400);
30        let secs = t.rem_euclid(86_400);
31        let (year, month, day) = civil_from_days(days);
32        Self {
33            year,
34            month,
35            day,
36            hour: (secs / 3600) as u8,
37            minute: (secs / 60 % 60) as u8,
38            second: (secs % 60) as u8,
39            // 1970-01-01 was a Thursday (ISO weekday 3).
40            weekday: (days + 3).rem_euclid(7) as u8,
41        }
42    }
43
44    /// Three-letter English weekday name.
45    pub const fn weekday_name(&self) -> &'static str {
46        match self.weekday {
47            0 => "Mon",
48            1 => "Tue",
49            2 => "Wed",
50            3 => "Thu",
51            4 => "Fri",
52            5 => "Sat",
53            _ => "Sun",
54        }
55    }
56}
57
58/// Days since 1970-01-01 → (year, month, day). Howard Hinnant's
59/// `civil_from_days`, valid for the whole `i32` year range.
60const fn civil_from_days(z: i64) -> (i32, u8, u8) {
61    let z = z + 719_468;
62    let era = z.div_euclid(146_097);
63    let doe = z.rem_euclid(146_097); // [0, 146096]
64    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
65    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
66    let mp = (5 * doy + 2) / 153; // [0, 11], March-based
67    let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
68    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
69    let year = (yoe + era * 400) as i32 + (month <= 2) as i32;
70    (year, month, day)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn ymd_hms(t: i64, off: i32) -> (i32, u8, u8, u8, u8, u8) {
78        let d = DateTime::from_unix(t, off);
79        (d.year, d.month, d.day, d.hour, d.minute, d.second)
80    }
81
82    #[test]
83    fn epoch_and_known_dates() {
84        assert_eq!(ymd_hms(0, 0), (1970, 1, 1, 0, 0, 0));
85        assert_eq!(DateTime::from_unix(0, 0).weekday_name(), "Thu");
86        assert_eq!(ymd_hms(951_782_400, 0), (2000, 2, 29, 0, 0, 0)); // leap day
87        assert_eq!(ymd_hms(1_767_225_600, 0), (2026, 1, 1, 0, 0, 0));
88        assert_eq!(DateTime::from_unix(1_767_225_600, 0).weekday_name(), "Thu");
89        assert_eq!(ymd_hms(4_102_444_799, 0), (2099, 12, 31, 23, 59, 59));
90        assert_eq!(ymd_hms(-1, 0), (1969, 12, 31, 23, 59, 59));
91    }
92
93    #[test]
94    fn utc_offsets_cross_midnight() {
95        // 2026-01-01T20:30Z at UTC+07:00 is 03:30 the next day.
96        assert_eq!(
97            ymd_hms(1_767_225_600 + 20 * 3600 + 1800, 420),
98            (2026, 1, 2, 3, 30, 0)
99        );
100        // And at UTC-05:00 it's 15:30 the same day.
101        assert_eq!(
102            ymd_hms(1_767_225_600 + 20 * 3600 + 1800, -300),
103            (2026, 1, 1, 15, 30, 0)
104        );
105    }
106
107    #[test]
108    fn every_day_increments_by_one() {
109        // Walk 800 years of days and check the calendar never skips.
110        let mut prev = DateTime::from_unix(0, 0);
111        for d in 1..(800 * 366) {
112            let cur = DateTime::from_unix(d * 86_400, 0);
113            let next_expected = (prev.day + 1, prev.month, prev.year);
114            assert!(
115                (cur.day, cur.month, cur.year) == next_expected
116                    || (cur.day == 1
117                        && (cur.month == prev.month + 1
118                            || (cur.month == 1 && cur.year == prev.year + 1))),
119                "{prev:?} -> {cur:?}"
120            );
121            assert_eq!(cur.weekday, (prev.weekday + 1) % 7);
122            prev = cur;
123        }
124    }
125}