Skip to main content

renox_core/
timezone.rs

1//! The app's time zone (`APP_TIMEZONE`): an IANA name such as
2//! `Asia/Jakarta` or `Europe/Amsterdam` (daylight saving time included), a
3//! fixed offset such as `+07:00`, or `UTC`. Scheduled tasks and the `date`
4//! template filter use it.
5//!
6//! ```
7//! use renox::timezone::Zone;
8//!
9//! let amsterdam: Zone = "Europe/Amsterdam".parse().unwrap();
10//! let winter = 1_767_225_600; // 2026-01-01 00:00 UTC
11//! let summer = 1_782_864_000; // 2026-07-01 00:00 UTC
12//! assert_eq!(amsterdam.offset_at(winter), 3600);
13//! assert_eq!(amsterdam.offset_at(summer), 7200);
14//! assert_eq!("+07:00".parse::<Zone>().unwrap().offset_at(winter), 7 * 3600);
15//! ```
16
17use std::fmt;
18use std::str::FromStr;
19
20use anyhow::{Context, bail};
21use chrono::{FixedOffset, LocalResult, NaiveDateTime, Offset, TimeZone};
22
23/// A time zone: UTC, a fixed offset or an IANA zone (with its daylight
24/// saving rules). Made by parsing (`"Asia/Jakarta".parse::<Zone>()`,
25/// `"+07:00".parse()`), or [`Zone::UTC`] and [`Zone::fixed`].
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Zone(Kind);
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum Kind {
31    /// A fixed offset from UTC, in seconds (`UTC` is 0).
32    Fixed(i32),
33    /// An IANA zone, with its daylight saving time rules.
34    Named(chrono_tz::Tz),
35}
36
37impl Default for Zone {
38    fn default() -> Self {
39        Zone::UTC
40    }
41}
42
43impl Zone {
44    /// Coordinated Universal Time.
45    pub const UTC: Zone = Zone(Kind::Fixed(0));
46
47    /// A fixed offset from UTC, in seconds east (`Zone::fixed(7 * 3600)` is
48    /// `+07:00`), with no daylight saving time.
49    pub const fn fixed(seconds: i32) -> Zone {
50        Zone(Kind::Fixed(seconds))
51    }
52
53    /// The offset from UTC at the moment `unix` (seconds), in seconds.
54    pub fn offset_at(&self, unix: i64) -> i64 {
55        match &self.0 {
56            Kind::Fixed(offset) => i64::from(*offset),
57            Kind::Named(tz) => {
58                let utc = chrono::DateTime::from_timestamp(unix, 0).unwrap_or_default();
59                i64::from(
60                    tz.offset_from_utc_datetime(&utc.naive_utc())
61                        .fix()
62                        .local_minus_utc(),
63                )
64            }
65        }
66    }
67
68    /// The wall-clock time at the moment `unix`.
69    pub fn local(&self, unix: i64) -> NaiveDateTime {
70        let utc = chrono::DateTime::from_timestamp(unix, 0).unwrap_or_default();
71        (utc + chrono::TimeDelta::seconds(self.offset_at(unix))).naive_utc()
72    }
73
74    /// The moment a wall-clock time happens. In the hour clocks skip in
75    /// spring it doesn't (`None`); in the hour repeated in autumn it's the
76    /// first one.
77    pub fn resolve(&self, local: NaiveDateTime) -> Option<i64> {
78        let result = match &self.0 {
79            Kind::Fixed(offset) => FixedOffset::east_opt(*offset)?
80                .from_local_datetime(&local)
81                .map(|t| t.timestamp()),
82            Kind::Named(tz) => tz.from_local_datetime(&local).map(|t| t.timestamp()),
83        };
84        match result {
85            LocalResult::Single(at) => Some(at),
86            LocalResult::Ambiguous(first, second) => Some(first.min(second)),
87            LocalResult::None => None,
88        }
89    }
90
91    /// The fixed offset in effect at `unix`, for chrono's formatting.
92    pub fn fixed_at(&self, unix: i64) -> FixedOffset {
93        FixedOffset::east_opt(self.offset_at(unix) as i32)
94            .unwrap_or_else(|| FixedOffset::east_opt(0).expect("zero offset"))
95    }
96}
97
98impl FromStr for Zone {
99    type Err = anyhow::Error;
100
101    /// `UTC`, `Z`, an offset (`+07:00`, `-03:30`) or an IANA name.
102    fn from_str(value: &str) -> anyhow::Result<Self> {
103        let value = value.trim();
104        if value.is_empty() || value.eq_ignore_ascii_case("utc") || value == "Z" {
105            return Ok(Zone::UTC);
106        }
107        if let Some(sign) = value
108            .strip_prefix('+')
109            .map(|rest| (1, rest))
110            .or_else(|| value.strip_prefix('-').map(|rest| (-1, rest)))
111        {
112            let (sign, rest) = sign;
113            let (hour, minute) = rest
114                .split_once(':')
115                .context("expected an offset like +07:00")?;
116            let (hour, minute): (i32, i32) = (hour.parse()?, minute.parse()?);
117            if !(0..=14).contains(&hour) || !(0..60).contains(&minute) {
118                bail!("`{value}` is not a UTC offset");
119            }
120            return Ok(Zone::fixed(sign * (hour * 3600 + minute * 60)));
121        }
122        match value.parse::<chrono_tz::Tz>() {
123            Ok(tz) => Ok(Zone(Kind::Named(tz))),
124            Err(_) => bail!(
125                "unknown time zone `{value}`: use UTC, an offset like +07:00 or an IANA name \
126                 like Asia/Jakarta"
127            ),
128        }
129    }
130}
131
132impl fmt::Display for Zone {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match &self.0 {
135            Kind::Fixed(0) => f.write_str("UTC"),
136            Kind::Fixed(offset) => {
137                let sign = if *offset < 0 { '-' } else { '+' };
138                let offset = offset.abs();
139                write!(f, "{sign}{:02}:{:02}", offset / 3600, offset % 3600 / 60)
140            }
141            Kind::Named(tz) => f.write_str(tz.name()),
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn at(text: &str) -> NaiveDateTime {
151        text.parse().unwrap()
152    }
153
154    #[test]
155    fn parses_every_form() {
156        assert_eq!("UTC".parse::<Zone>().unwrap(), Zone::UTC);
157        assert_eq!("".parse::<Zone>().unwrap(), Zone::UTC);
158        assert_eq!(
159            "-03:30".parse::<Zone>().unwrap(),
160            Zone::fixed(-(3 * 3600 + 1800))
161        );
162        assert_eq!(
163            "Asia/Jakarta".parse::<Zone>().unwrap().to_string(),
164            "Asia/Jakarta"
165        );
166        assert_eq!("+07:00".parse::<Zone>().unwrap().to_string(), "+07:00");
167        assert!("Mars/Olympus".parse::<Zone>().is_err());
168        assert!("+25:00".parse::<Zone>().is_err());
169        assert!("+7".parse::<Zone>().is_err());
170    }
171
172    #[test]
173    fn daylight_saving_gaps_and_repeats() {
174        let ams: Zone = "Europe/Amsterdam".parse().unwrap();
175        // 2026-03-29: 02:00 → 03:00, so 02:30 doesn't happen.
176        assert_eq!(ams.resolve(at("2026-03-29T02:30:00")), None);
177        let three = ams.resolve(at("2026-03-29T03:00:00")).unwrap();
178        assert_eq!(ams.offset_at(three), 7200);
179        // 2026-10-25: 03:00 → 02:00, so 02:30 happens twice; the first counts.
180        let twice = ams.resolve(at("2026-10-25T02:30:00")).unwrap();
181        assert_eq!(ams.offset_at(twice), 7200);
182        assert_eq!(ams.local(twice), at("2026-10-25T02:30:00"));
183        assert_eq!(
184            ams.local(twice + 3600),
185            at("2026-10-25T02:30:00"),
186            "the repeat"
187        );
188    }
189}