icu_time/zone/
zone_name_timestamp.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use core::fmt;
6
7use icu_calendar::Iso;
8use zerovec::ule::AsULE;
9
10use crate::{zone::UtcOffset, DateTime, ZonedDateTime};
11
12/// The moment in time for resolving a time zone name.
13///
14/// **What is this for?** Most software deals with _time zone transitions_,
15/// computing the UTC offset on a given point in time. In ICU4X, we deal with
16/// _time zone display names_. Whereas time zone transitions occur multiple
17/// times per year in some time zones, the set of display names changes more
18/// rarely. For example, ICU4X needs to know when a region switches from
19/// Eastern Time to Central Time.
20///
21/// This type can only represent display name changes after 1970, and only to
22/// a coarse (15-minute) granularity, which is sufficient for CLDR and TZDB
23/// data within that time frame.
24///
25/// # Examples
26///
27/// The region of Metlakatla (Alaska) switched between Pacific Time and
28/// Alaska Time multiple times between 2010 and 2025.
29///
30/// ```
31/// use icu::calendar::Iso;
32/// use icu::datetime::fieldsets::zone::GenericLong;
33/// use icu::datetime::NoCalendarFormatter;
34/// use icu::locale::locale;
35/// use icu::time::zone::IanaParser;
36/// use icu::time::zone::ZoneNameTimestamp;
37/// use icu::time::ZonedDateTime;
38/// use writeable::assert_writeable_eq;
39///
40/// let metlakatla = IanaParser::new().parse("America/Metlakatla");
41///
42/// let zone_formatter =
43///     NoCalendarFormatter::try_new(locale!("en-US").into(), GenericLong)
44///         .unwrap();
45///
46/// let time_zone_info_2010 = metlakatla
47///     .without_offset()
48///     .with_zone_name_timestamp(ZoneNameTimestamp::from_zoned_date_time_iso(
49///         ZonedDateTime::try_offset_only_from_str("2010-01-01T00:00Z", Iso)
50///             .unwrap(),
51///     ));
52/// let time_zone_info_2025 = metlakatla
53///     .without_offset()
54///     .with_zone_name_timestamp(ZoneNameTimestamp::from_zoned_date_time_iso(
55///         ZonedDateTime::try_offset_only_from_str("2025-01-01T00:00Z", Iso)
56///             .unwrap(),
57///     ));
58///
59/// // Check the display names:
60/// let name_2010 = zone_formatter.format(&time_zone_info_2010);
61/// let name_2025 = zone_formatter.format(&time_zone_info_2025);
62///
63/// assert_writeable_eq!(name_2010, "Pacific Time");
64/// assert_writeable_eq!(name_2025, "Alaska Time");
65/// ```
66#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
67pub struct ZoneNameTimestamp(u32);
68
69impl ZoneNameTimestamp {
70    /// Recovers the UTC datetime for this [`ZoneNameTimestamp`].
71    ///
72    /// This will always return a [`ZonedDateTime`] with [`UtcOffset::zero()`]
73    pub fn to_zoned_date_time_iso(self) -> ZonedDateTime<Iso, UtcOffset> {
74        ZonedDateTime::from_epoch_milliseconds_and_utc_offset(
75            match self.0 as i64 * 15 * 60 * 1000 {
76                // See `from_zoned_date_time_iso`
77                63593100000 => 63593070000,
78                307622700000 => 307622400000,
79                576042300000 => 576041460000,
80                576044100000 => 576043260000,
81                594180900000 => 594180060000,
82                607491900000 => 607491060000,
83                1601741700000 => 1601740860000,
84                1633191300000 => 1633190460000,
85                1664640900000 => 1664640060000,
86                ms => ms,
87            },
88            UtcOffset::zero(),
89        )
90    }
91
92    /// Creates an instance of [`ZoneNameTimestamp`] from a zoned datetime.
93    ///
94    /// The datetime might be clamped and might lose precision.
95    ///
96    /// # Examples
97    ///
98    /// ZonedDateTime does _not_ necessarily roundtrip:
99    ///
100    /// ```
101    /// use icu::calendar::Date;
102    /// use icu::time::zone::ZoneNameTimestamp;
103    /// use icu::time::{ZonedDateTime, Time, zone::UtcOffset};
104    ///
105    /// let zoned_date_time = ZonedDateTime {
106    ///     date: Date::try_new_iso(2025, 4, 30).unwrap(),
107    ///     time: Time::try_new(13, 58, 16, 500000000).unwrap(),
108    ///     zone: UtcOffset::zero(),
109    /// };
110    ///
111    /// let zone_name_timestamp = ZoneNameTimestamp::from_zoned_date_time_iso(zoned_date_time);
112    ///
113    /// let recovered_zoned_date_time = zone_name_timestamp.to_zoned_date_time_iso();
114    ///
115    /// // The datetime doesn't roundtrip:
116    /// assert_ne!(zoned_date_time, recovered_zoned_date_time);
117    ///
118    /// // The exact behavior is subject to change. For illustration only:
119    /// assert_eq!(recovered_zoned_date_time.date, zoned_date_time.date);
120    /// assert_eq!(recovered_zoned_date_time.time.hour, zoned_date_time.time.hour);
121    /// assert_eq!(recovered_zoned_date_time.time.minute.number(), 45); // rounded down
122    /// assert_eq!(recovered_zoned_date_time.time.second.number(), 0); // always zero
123    /// assert_eq!(recovered_zoned_date_time.time.subsecond.number(), 0); // always zero
124    /// ```
125    pub fn from_zoned_date_time_iso(zoned_date_time: ZonedDateTime<Iso, UtcOffset>) -> Self {
126        let ms = match zoned_date_time.to_epoch_milliseconds_utc() {
127            // Values that are not multiples of 15, that we map to the next multiple
128            // of 15 (which is always 00:15 or 00:45, values that are otherwise unused).
129            63593070000..63593100000 => 63593100000,
130            307622400000..307622700000 => 307622700000,
131            576041460000..576042300000 => 576042300000,
132            576043260000..576044100000 => 576044100000,
133            594180060000..594180900000 => 594180900000,
134            607491060000..607491900000 => 607491900000,
135            1601740860000..1601741700000 => 1601741700000,
136            1633190460000..1633191300000 => 1633191300000,
137            1664640060000..1664640900000 => 1664640900000,
138            ms => ms,
139        };
140        let qh = ms / 1000 / 60 / 15;
141        let qh_clamped = qh.clamp(Self::far_in_past().0 as i64, Self::far_in_future().0 as i64);
142        // Valid cast as the value is clamped to u32 values.
143        Self(qh_clamped as u32)
144    }
145
146    /// Recovers the UTC datetime for this [`ZoneNameTimestamp`].
147    #[deprecated(
148        since = "2.1.0",
149        note = "returns a UTC DateTime, which is the wrong type. Use `to_zoned_date_time_iso` instead"
150    )]
151    pub fn to_date_time_iso(self) -> DateTime<Iso> {
152        let ZonedDateTime {
153            date,
154            time,
155            zone: _utc_offset_zero,
156        } = self.to_zoned_date_time_iso();
157        DateTime { date, time }
158    }
159
160    /// Creates an instance of [`ZoneNameTimestamp`] from a UTC datetime.
161    ///
162    /// The datetime might be clamped and might lose precision.
163    #[deprecated(
164        since = "2.1.0",
165        note = "implicitly interprets the DateTime as UTC. Use `from_zoned_date_time_iso` instead."
166    )]
167    pub fn from_date_time_iso(DateTime { date, time }: DateTime<Iso>) -> Self {
168        Self::from_zoned_date_time_iso(ZonedDateTime {
169            date,
170            time,
171            zone: UtcOffset::zero(),
172        })
173    }
174
175    /// Returns a [`ZoneNameTimestamp`] for a time far in the past.
176    pub fn far_in_past() -> Self {
177        Self(0)
178    }
179
180    /// Returns a [`ZoneNameTimestamp`] for a time far in the future.
181    pub fn far_in_future() -> Self {
182        Self(0xFFFFFF)
183    }
184}
185
186impl fmt::Debug for ZoneNameTimestamp {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        fmt::Debug::fmt(&self.to_zoned_date_time_iso(), f)
189    }
190}
191
192impl AsULE for ZoneNameTimestamp {
193    type ULE = <u32 as AsULE>::ULE;
194    #[inline]
195    fn to_unaligned(self) -> Self::ULE {
196        self.0.to_unaligned()
197    }
198    #[inline]
199    fn from_unaligned(unaligned: Self::ULE) -> Self {
200        Self(u32::from_unaligned(unaligned))
201    }
202}
203
204#[cfg(feature = "alloc")]
205impl<'a> zerovec::maps::ZeroMapKV<'a> for ZoneNameTimestamp {
206    type Container = zerovec::ZeroVec<'a, Self>;
207    type Slice = zerovec::ZeroSlice<Self>;
208    type GetType = <Self as AsULE>::ULE;
209    type OwnedType = Self;
210}
211
212#[cfg(feature = "serde")]
213impl serde::Serialize for ZoneNameTimestamp {
214    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
215    where
216        S: serde::Serializer,
217    {
218        #[cfg(feature = "alloc")]
219        if serializer.is_human_readable() {
220            let date_time = self.to_zoned_date_time_iso();
221            let year = date_time.date.era_year().year;
222            let month = date_time.date.month().month_number();
223            let day = date_time.date.day_of_month().0;
224            let hour = date_time.time.hour.number();
225            let minute = date_time.time.minute.number();
226            let second = date_time.time.second.number();
227            let mut s = alloc::format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}");
228            if second != 0 {
229                use alloc::fmt::Write;
230                let _infallible = write!(&mut s, ":{second:02}");
231            }
232            // don't serialize the metadata for now
233            return serializer.serialize_str(&s);
234        }
235        serializer.serialize_u32(self.0)
236    }
237}
238
239#[cfg(feature = "serde")]
240impl<'de> serde::Deserialize<'de> for ZoneNameTimestamp {
241    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242    where
243        D: serde::Deserializer<'de>,
244    {
245        #[cfg(feature = "alloc")]
246        if deserializer.is_human_readable() {
247            use serde::de::Error;
248            let e0 = D::Error::custom("invalid");
249            let e1 = |_| D::Error::custom("invalid");
250            let e2 = |_| D::Error::custom("invalid");
251            let e3 = |_| D::Error::custom("invalid");
252
253            let parts = alloc::borrow::Cow::<'de, str>::deserialize(deserializer)?;
254            if parts.len() != 16 {
255                return Err(e0);
256            }
257            let year = parts[0..4].parse::<i32>().map_err(e1)?;
258            let month = parts[5..7].parse::<u8>().map_err(e1)?;
259            let day = parts[8..10].parse::<u8>().map_err(e1)?;
260            let hour = parts[11..13].parse::<u8>().map_err(e1)?;
261            let minute = parts[14..16].parse::<u8>().map_err(e1)?;
262            return Ok(Self::from_zoned_date_time_iso(ZonedDateTime {
263                date: icu_calendar::Date::try_new_iso(year, month, day).map_err(e2)?,
264                time: crate::Time::try_new(hour, minute, 0, 0).map_err(e3)?,
265                zone: UtcOffset::zero(),
266            }));
267        }
268        u32::deserialize(deserializer).map(Self)
269    }
270}
271
272#[cfg(test)]
273mod test {
274    use super::*;
275
276    #[test]
277    fn test_packing() {
278        #[derive(Debug)]
279        struct TestCase {
280            input: &'static str,
281            output: &'static str,
282        }
283        for test_case in [
284            // Behavior at the epoch
285            TestCase {
286                input: "1970-01-01T00:00Z",
287                output: "1970-01-01T00:00Z",
288            },
289            TestCase {
290                input: "1970-01-01T00:01Z",
291                output: "1970-01-01T00:00Z",
292            },
293            TestCase {
294                input: "1970-01-01T00:15Z",
295                output: "1970-01-01T00:15Z",
296            },
297            TestCase {
298                input: "1970-01-01T00:29Z",
299                output: "1970-01-01T00:15Z",
300            },
301            // Min Value Clamping
302            TestCase {
303                input: "1969-12-31T23:59Z",
304                output: "1970-01-01T00:00Z",
305            },
306            TestCase {
307                input: "1969-12-31T12:00Z",
308                output: "1970-01-01T00:00Z",
309            },
310            TestCase {
311                input: "1900-07-15T12:34Z",
312                output: "1970-01-01T00:00Z",
313            },
314            // Max Value Clamping
315            TestCase {
316                input: "2448-06-25T15:45Z",
317                output: "2448-06-25T15:45Z",
318            },
319            TestCase {
320                input: "2448-06-25T16:00Z",
321                output: "2448-06-25T15:45Z",
322            },
323            TestCase {
324                input: "2448-06-26T00:00Z",
325                output: "2448-06-25T15:45Z",
326            },
327            TestCase {
328                input: "2500-01-01T00:00Z",
329                output: "2448-06-25T15:45Z",
330            },
331            // Offset adjusments
332            TestCase {
333                input: "2025-10-10T10:15+02",
334                output: "2025-10-10T08:15Z",
335            },
336            // Other cases
337            TestCase {
338                input: "2025-04-30T15:18:25Z",
339                output: "2025-04-30T15:15Z",
340            },
341        ] {
342            let znt = ZoneNameTimestamp::from_zoned_date_time_iso(
343                ZonedDateTime::try_offset_only_from_str(test_case.input, Iso).unwrap(),
344            );
345            let actual = znt.to_zoned_date_time_iso();
346            assert_eq!(
347                ZonedDateTime::try_offset_only_from_str(test_case.output, Iso).unwrap(),
348                actual,
349                "{test_case:?}"
350            );
351        }
352    }
353}