Skip to main content

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