Skip to main content

sofie_core/
display_zone.rs

1use std::sync::OnceLock;
2
3use jiff::civil::DateTime;
4use jiff::tz::{AmbiguousOffset, TimeZone};
5
6use crate::{Error, FieldError, Instant, Result};
7
8/// Process-wide because an instant read from a form deserializes with no request at hand.
9static INSTALLED: OnceLock<DisplayZone> = OnceLock::new();
10
11/// Where people see and type instants; stored and JSON instants stay UTC.
12#[derive(Clone, Debug)]
13pub struct DisplayZone {
14    zone: TimeZone,
15}
16
17impl DisplayZone {
18    pub fn named(name: &str) -> Result<Self> {
19        let refused = || format!("`{name}` is not an IANA time zone such as Europe/Istanbul");
20        let zone = TimeZone::get(name.trim()).map_err(|error| Error::internal(refused(), error))?;
21        if !zone.iana_name().is_some_and(is_iana_identifier) {
22            return Err(Error::internal_message(refused()));
23        }
24        Ok(Self { zone })
25    }
26
27    /// UTC unless installed first; reading it fixes it, so no instant is read in two zones.
28    #[must_use]
29    pub fn current() -> &'static Self {
30        INSTALLED.get_or_init(|| Self {
31            zone: TimeZone::UTC,
32        })
33    }
34
35    pub fn install(self) -> Result<()> {
36        let installed = INSTALLED.get_or_init(|| self.clone());
37        if installed.name() == self.name() {
38            return Ok(());
39        }
40        Err(Error::internal_message(format!(
41            "the display zone is {}, fixed when first installed or first read, not {}",
42            installed.name(),
43            self.name()
44        )))
45    }
46
47    #[must_use]
48    pub fn name(&self) -> &str {
49        self.zone
50            .iana_name()
51            .expect("UTC and a zone found in the database carry their name")
52    }
53
54    #[must_use]
55    pub fn wall_clock(&self, instant: Instant) -> DateTime {
56        self.zone.to_datetime(instant.timestamp())
57    }
58
59    /// True where a backward shift repeats it, so only an offset tells its two instants apart.
60    #[must_use]
61    pub fn repeats(&self, wall_clock: DateTime) -> bool {
62        matches!(
63            self.zone.to_ambiguous_timestamp(wall_clock).offset(),
64            AmbiguousOffset::Fold { .. }
65        )
66    }
67
68    #[must_use]
69    pub fn repeated_offset(&self, instant: Instant) -> Option<String> {
70        let zoned = instant.timestamp().to_zoned(self.zone.clone());
71        self.repeats(zoned.datetime())
72            .then(|| zoned.strftime("%:z").to_string())
73    }
74
75    /// On the zone's wall clock with its offset there, such as `2026-10-25T02:30:00+01:00`.
76    #[must_use]
77    pub fn with_offset(&self, instant: Instant) -> String {
78        let offset = self.zone.to_offset(instant.timestamp());
79        instant.timestamp().display_with_offset(offset).to_string()
80    }
81
82    pub fn instant_at(&self, wall_clock: DateTime) -> Result<Instant> {
83        let placed = self.zone.to_ambiguous_timestamp(wall_clock);
84        if let AmbiguousOffset::Fold { before, after } = placed.offset() {
85            let [earlier, later] = [before, after].map(|offset| {
86                offset
87                    .to_timestamp(wall_clock)
88                    .expect("the zone's own offsets place a time it repeats")
89                    .display_with_offset(offset)
90                    .to_string()
91            });
92            let refusal = FieldError::new(
93                "instant",
94                "validation.instant.repeated",
95                format!(
96                    "{wall_clock} happens twice in {}; give its offset: {earlier} or {later}",
97                    self.name()
98                ),
99            );
100            return Err(Error::Invalid(vec![
101                refusal
102                    .with("wall_clock", wall_clock.to_string())
103                    .with("zone", self.name())
104                    .with("earlier", earlier)
105                    .with("later", later),
106            ]));
107        }
108        wall_clock_instant(&self.zone, wall_clock)
109            .map(Instant::from)
110            .ok_or_else(|| {
111                let refusal = FieldError::new(
112                    "instant",
113                    "validation.instant.out_of_range",
114                    format!("{wall_clock} in {} is out of range", self.name()),
115                );
116                Error::Invalid(vec![
117                    refusal
118                        .with("wall_clock", wall_clock.to_string())
119                        .with("zone", self.name()),
120                ])
121            })
122    }
123}
124
125/// A time a forward shift skips goes where the shift ends, a repeated one to its first occurrence.
126#[must_use]
127pub fn wall_clock_instant(zone: &TimeZone, local: DateTime) -> Option<jiff::Timestamp> {
128    let ambiguous = zone.to_ambiguous_timestamp(local);
129    let earlier = ambiguous.earlier().ok()?;
130    if !matches!(ambiguous.offset(), AmbiguousOffset::Gap { .. }) {
131        return Some(earlier);
132    }
133    zone.following(earlier)
134        .next()
135        .map(|shift| shift.timestamp())
136}
137
138/// The zoneinfo directory also holds `posixrules`, `Factory`, `+VERSION` and tables, which a
139/// browser's `Intl` refuses; tzdb names its zones with components that start with a capital.
140#[must_use]
141pub fn is_iana_identifier(name: &str) -> bool {
142    name != "Factory"
143        && name.split('/').all(|component| {
144            component.starts_with(|first: char| first.is_ascii_uppercase())
145                && component
146                    .chars()
147                    .all(|character| character.is_ascii_alphanumeric() || "_-+".contains(character))
148        })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn shows_an_instant_on_the_zone_s_wall_clock_and_reads_it_back() {
157        let zone = DisplayZone::named("Europe/Istanbul").unwrap();
158        let instant = Instant::parse("2026-10-11T09:30:00Z").unwrap();
159
160        let wall_clock = zone.wall_clock(instant);
161
162        assert_eq!(wall_clock.to_string(), "2026-10-11T12:30:00");
163        assert_eq!(zone.instant_at(wall_clock).unwrap(), instant);
164        assert_eq!(zone.name(), "Europe/Istanbul");
165    }
166
167    #[test]
168    fn writes_an_instant_in_a_repeated_hour_with_the_offset_that_reads_it_back() {
169        let london = DisplayZone::named("Europe/London").unwrap();
170        for (at, written) in [
171            ("2026-10-25T00:30:00Z", "2026-10-25T01:30:00+01:00"),
172            ("2026-10-25T01:30:15.5Z", "2026-10-25T01:30:15.5+00:00"),
173        ] {
174            let instant = Instant::parse(at).unwrap();
175
176            assert!(london.repeats(london.wall_clock(instant)));
177            assert_eq!(london.with_offset(instant), written);
178            assert_eq!(Instant::parse_in(written, &london).unwrap(), instant);
179        }
180        let outside = Instant::parse("2026-10-25T02:30:00Z").unwrap();
181        assert!(!london.repeats(london.wall_clock(outside)));
182    }
183
184    #[test]
185    fn names_an_instant_s_offset_only_where_its_wall_clock_repeats() {
186        let berlin = DisplayZone::named("Europe/Berlin").unwrap();
187        let offset = |at: &str| berlin.repeated_offset(Instant::parse(at).unwrap());
188
189        assert_eq!(offset("2026-10-25T00:30:00Z").as_deref(), Some("+02:00"));
190        assert_eq!(offset("2026-10-25T01:30:00Z").as_deref(), Some("+01:00"));
191        assert_eq!(offset("2026-10-25T02:30:00Z"), None);
192    }
193
194    #[test]
195    fn refuses_an_unknown_zone_naming_it() {
196        let error = DisplayZone::named("Mars/Olympus").unwrap_err();
197        assert!(error.to_string().contains("`Mars/Olympus`"), "{error}");
198    }
199
200    #[test]
201    fn refuses_a_zone_a_browser_cannot_show_with_the_same_message() {
202        for name in [
203            "posix/Europe/Istanbul",
204            "right/Europe/Istanbul",
205            "Factory",
206            "localtime",
207            "posixrules",
208            "+VERSION",
209            "zone.tab",
210        ] {
211            let error = DisplayZone::named(name).unwrap_err();
212            assert!(
213                error.to_string().contains("is not an IANA time zone"),
214                "{name}: {error}"
215            );
216        }
217    }
218
219    #[test]
220    fn refuses_the_unknown_zone_which_has_no_iana_name() {
221        let error = DisplayZone::named("Etc/Unknown").unwrap_err();
222        assert!(error.to_string().contains("`Etc/Unknown`"), "{error}");
223    }
224
225    #[test]
226    fn keeps_the_database_s_spelling_of_a_zone() {
227        assert_eq!(
228            DisplayZone::named("europe/istanbul").unwrap().name(),
229            "Europe/Istanbul"
230        );
231        assert_eq!(DisplayZone::named("utc").unwrap().name(), "UTC");
232    }
233
234    #[test]
235    fn reading_the_zone_fixes_it_so_a_later_other_zone_is_refused() {
236        assert_eq!(DisplayZone::current().name(), "UTC");
237
238        let error = DisplayZone::named("Europe/Berlin")
239            .unwrap()
240            .install()
241            .unwrap_err();
242
243        assert!(error.to_string().contains("first read"), "{error}");
244        assert_eq!(DisplayZone::current().name(), "UTC");
245        assert!(DisplayZone::named("UTC").unwrap().install().is_ok());
246    }
247
248    #[test]
249    fn is_utc_until_installed() {
250        assert_eq!(
251            DisplayZone::current()
252                .wall_clock(Instant::parse("2026-10-11T09:30:00Z").unwrap())
253                .to_string(),
254            "2026-10-11T09:30:00"
255        );
256    }
257}