Skip to main content

egui_charts/model/
timezone.rs

1//! Time zones with optional IANA daylight-saving rules.
2
3use std::{borrow::Cow, fmt, str::FromStr};
4
5use chrono::{
6    DateTime, Duration, FixedOffset, LocalResult, NaiveDate, NaiveDateTime, Offset, TimeZone,
7    Timelike, Utc,
8};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11/// A validated time zone, independent of the optional time zone database.
12///
13/// UTC and fixed offsets (`+05:45`, `-03:30`, or `+00:19:32`) work in every
14/// build. IANA names such as `America/New_York` need the `timezones` feature,
15/// enabled by default. Unavailable names are rejected rather than approximated.
16#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17pub struct TimezoneId {
18    name: Cow<'static, str>,
19    zone: Zone,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23enum Zone {
24    Fixed(FixedOffset),
25    #[cfg(feature = "timezones")]
26    Named(chrono_tz::Tz),
27}
28
29impl TimezoneId {
30    /// Coordinated Universal Time.
31    pub fn utc() -> Self {
32        Self {
33            name: Cow::Borrowed("UTC"),
34            zone: Zone::Fixed(FixedOffset::east_opt(0).unwrap()),
35        }
36    }
37
38    /// The zone of an IANA name or fixed offset, if available in this build.
39    pub fn named(name: &str) -> Option<Self> {
40        name.parse().ok()
41    }
42
43    /// A constant offset east of UTC, in seconds, strictly between -24 and +24 hours.
44    /// Negative values are west of UTC; fixed offsets have no daylight-saving changes.
45    pub fn fixed_offset(seconds: i32) -> Option<Self> {
46        let offset = FixedOffset::east_opt(seconds)?;
47        Some(Self {
48            name: Cow::Owned(offset.to_string()),
49            zone: Zone::Fixed(offset),
50        })
51    }
52
53    /// The IANA name, `UTC`, or signed fixed offset.
54    pub fn name(&self) -> &str {
55        &self.name
56    }
57
58    fn offset(&self, fixed: FixedOffset) -> TimezoneOffset {
59        TimezoneOffset {
60            zone: self.clone(),
61            fixed,
62        }
63    }
64}
65
66impl Default for TimezoneId {
67    fn default() -> Self {
68        Self::utc()
69    }
70}
71
72impl FromStr for TimezoneId {
73    type Err = super::ParseError;
74
75    fn from_str(name: &str) -> Result<Self, Self::Err> {
76        if name == "UTC" {
77            return Ok(Self::utc());
78        }
79        let bytes = name.as_bytes();
80        if matches!(bytes.first(), Some(b'+' | b'-')) {
81            let valid = matches!(bytes.len(), 6 | 9)
82                && bytes[3] == b':'
83                && (bytes.len() == 6 || bytes[6] == b':')
84                && bytes.iter().enumerate().skip(1).all(|(i, b)| {
85                    if i == 3 || i == 6 {
86                        *b == b':'
87                    } else {
88                        b.is_ascii_digit()
89                    }
90                });
91            if valid {
92                let number = |i| i32::from(bytes[i] - b'0') * 10 + i32::from(bytes[i + 1] - b'0');
93                let (hours, minutes) = (number(1), number(4));
94                let seconds = if bytes.len() == 9 { number(7) } else { 0 };
95                if hours < 24 && minutes < 60 && seconds < 60 {
96                    let offset = hours * 3600 + minutes * 60 + seconds;
97                    return Self::fixed_offset(if bytes[0] == b'-' { -offset } else { offset })
98                        .ok_or_else(|| super::ParseError::new("time zone", name, "a fixed offset reads +HH:MM or -HH:MM, optionally with :SS, below 24 hours"));
99                }
100            }
101            return Err(super::ParseError::new(
102                "time zone",
103                name,
104                "a fixed offset reads +HH:MM or -HH:MM, optionally with :SS, below 24 hours",
105            ));
106        }
107        #[cfg(feature = "timezones")]
108        if let Ok(zone) = name.parse::<chrono_tz::Tz>() {
109            return Ok(Self {
110                name: Cow::Borrowed(zone.name()),
111                zone: Zone::Named(zone),
112            });
113        }
114        Err(super::ParseError::new(
115            "time zone",
116            name,
117            if cfg!(feature = "timezones") {
118                "unknown IANA time zone"
119            } else {
120                "named time zones need the timezones feature"
121            },
122        ))
123    }
124}
125
126impl fmt::Display for TimezoneId {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(self.name())
129    }
130}
131
132impl Serialize for TimezoneId {
133    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
134        serializer.serialize_str(self.name())
135    }
136}
137
138impl<'de> Deserialize<'de> for TimezoneId {
139    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
140        String::deserialize(deserializer)?
141            .parse()
142            .map_err(serde::de::Error::custom)
143    }
144}
145
146/// An instant's UTC offset, retaining its zone for chrono calendar operations.
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub struct TimezoneOffset {
149    zone: TimezoneId,
150    fixed: FixedOffset,
151}
152
153impl Offset for TimezoneOffset {
154    fn fix(&self) -> FixedOffset {
155        self.fixed
156    }
157}
158
159impl fmt::Display for TimezoneOffset {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        self.fixed.fmt(f)
162    }
163}
164
165impl TimeZone for TimezoneId {
166    type Offset = TimezoneOffset;
167
168    fn from_offset(offset: &Self::Offset) -> Self {
169        offset.zone.clone()
170    }
171
172    fn offset_from_local_date(&self, _local: &NaiveDate) -> LocalResult<Self::Offset> {
173        match self.zone {
174            Zone::Fixed(offset) => LocalResult::Single(self.offset(offset)),
175            #[cfg(feature = "timezones")]
176            Zone::Named(zone) => zone
177                .offset_from_local_date(_local)
178                .map(|offset| self.offset(offset.fix())),
179        }
180    }
181
182    fn offset_from_local_datetime(&self, _local: &NaiveDateTime) -> LocalResult<Self::Offset> {
183        match self.zone {
184            Zone::Fixed(offset) => LocalResult::Single(self.offset(offset)),
185            #[cfg(feature = "timezones")]
186            Zone::Named(zone) => zone
187                .offset_from_local_datetime(_local)
188                .map(|offset| self.offset(offset.fix())),
189        }
190    }
191
192    fn offset_from_utc_date(&self, _utc: &NaiveDate) -> Self::Offset {
193        match self.zone {
194            Zone::Fixed(offset) => self.offset(offset),
195            #[cfg(feature = "timezones")]
196            Zone::Named(zone) => self.offset(zone.offset_from_utc_date(_utc).fix()),
197        }
198    }
199
200    fn offset_from_utc_datetime(&self, _utc: &NaiveDateTime) -> Self::Offset {
201        match self.zone {
202            Zone::Fixed(offset) => self.offset(offset),
203            #[cfg(feature = "timezones")]
204            Zone::Named(zone) => self.offset(zone.offset_from_utc_datetime(_utc).fix()),
205        }
206    }
207}
208
209/// Local fields, falling back to UTC only beyond the representable date range.
210pub(crate) fn local_time<T: TimeZone>(zone: T, time: DateTime<Utc>) -> NaiveDateTime {
211    time.naive_utc()
212        .checked_add_offset(zone.offset_from_utc_datetime(&time.naive_utc()).fix())
213        .unwrap_or(time.naive_utc())
214}
215
216/// The first instant of a repeated wall time; a skipped time opens at the clock jump.
217pub(crate) fn local_instant<T: TimeZone>(
218    zone: T,
219    mut local: NaiveDateTime,
220) -> Option<DateTime<Utc>> {
221    if let Some(time) = zone.from_local_datetime(&local).earliest() {
222        return Some(time.to_utc());
223    }
224    local = local.with_nanosecond(0)?;
225    for _ in 0..=24 * 60 * 60 {
226        local = local.checked_add_signed(Duration::seconds(1))?;
227        if let Some(time) = zone.from_local_datetime(&local).earliest() {
228            return Some(time.to_utc());
229        }
230    }
231    None
232}