Skip to main content

chrono_node/core/
timezone.rs

1//! upstream `timezone.ts`: time zone abbreviations and `toTimezoneOffset`.
2
3use super::components::weekday;
4use super::js_date::JsDate;
5use std::collections::BTreeMap;
6
7/// A DST boundary for a year (upstream `dstStart` / `dstEnd`).
8pub type DstRule = fn(i64) -> JsDate;
9
10/// upstream `number | AmbiguousTimezoneMap`.
11#[derive(Clone, Copy, Debug)]
12pub enum TimezoneSpec {
13    /// A fixed offset in minutes.
14    Fixed(i64),
15    /// An abbreviation whose offset depends on DST at the reference date (e.g. `ET`).
16    Ambiguous {
17        offset_during_dst: i64,
18        offset_non_dst: i64,
19        dst_start: DstRule,
20        dst_end: DstRule,
21    },
22}
23
24impl PartialEq for TimezoneSpec {
25    fn eq(&self, other: &Self) -> bool {
26        use std::ptr::fn_addr_eq;
27        match (self, other) {
28            (Fixed(a), Fixed(b)) => a == b,
29            (
30                TimezoneSpec::Ambiguous {
31                    offset_during_dst: a1,
32                    offset_non_dst: a2,
33                    dst_start: a3,
34                    dst_end: a4,
35                },
36                TimezoneSpec::Ambiguous {
37                    offset_during_dst: b1,
38                    offset_non_dst: b2,
39                    dst_start: b3,
40                    dst_end: b4,
41                },
42            ) => a1 == b1 && a2 == b2 && fn_addr_eq(*a3, *b3) && fn_addr_eq(*a4, *b4),
43            _ => false,
44        }
45    }
46}
47
48/// upstream `TimezoneAbbrMap` (used for `ParsingOption.timezones` overrides).
49pub type TimezoneAbbrMap = BTreeMap<String, TimezoneSpec>;
50
51/// upstream `ParsingReference.timezone`: an abbreviation or an offset in minutes.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub enum TimezoneInput {
54    Abbr(String),
55    Minutes(i64),
56}
57
58use TimezoneSpec::Fixed;
59
60fn cet_dst_start(year: i64) -> JsDate {
61    get_last_weekday_of_month(year, 3, weekday::SUNDAY, 2)
62}
63fn cet_dst_end(year: i64) -> JsDate {
64    get_last_weekday_of_month(year, 10, weekday::SUNDAY, 3)
65}
66fn us_dst_start(year: i64) -> JsDate {
67    get_nth_weekday_of_month(year, 3, weekday::SUNDAY, 2, 2)
68}
69fn us_dst_end(year: i64) -> JsDate {
70    get_nth_weekday_of_month(year, 11, weekday::SUNDAY, 1, 2)
71}
72
73const fn us(during_dst: i64, non_dst: i64) -> TimezoneSpec {
74    TimezoneSpec::Ambiguous {
75        offset_during_dst: during_dst,
76        offset_non_dst: non_dst,
77        dst_start: us_dst_start,
78        dst_end: us_dst_end,
79    }
80}
81
82/// upstream `TIMEZONE_ABBR_MAP` (case-sensitive keys, like upstream).
83pub static TIMEZONE_ABBR_MAP: &[(&str, TimezoneSpec)] = &[
84    (
85        "CET",
86        TimezoneSpec::Ambiguous {
87            offset_during_dst: 120,
88            offset_non_dst: 60,
89            dst_start: cet_dst_start,
90            dst_end: cet_dst_end,
91        },
92    ),
93    ("CT", us(-300, -360)),
94    ("ET", us(-240, -300)),
95    ("MT", us(-360, -420)),
96    ("PT", us(-420, -480)),
97    ("ACDT", Fixed(630)),
98    ("ACST", Fixed(570)),
99    ("ADT", Fixed(-180)),
100    ("AEDT", Fixed(660)),
101    ("AEST", Fixed(600)),
102    ("AFT", Fixed(270)),
103    ("AKDT", Fixed(-480)),
104    ("AKST", Fixed(-540)),
105    ("ALMT", Fixed(360)),
106    ("AMST", Fixed(-180)),
107    ("AMT", Fixed(-240)),
108    ("ANAST", Fixed(720)),
109    ("ANAT", Fixed(720)),
110    ("AQTT", Fixed(300)),
111    ("ART", Fixed(-180)),
112    ("AST", Fixed(-240)),
113    ("AWDT", Fixed(540)),
114    ("AWST", Fixed(480)),
115    ("AZOST", Fixed(0)),
116    ("AZOT", Fixed(-60)),
117    ("AZST", Fixed(300)),
118    ("AZT", Fixed(240)),
119    ("BNT", Fixed(480)),
120    ("BOT", Fixed(-240)),
121    ("BRST", Fixed(-120)),
122    ("BRT", Fixed(-180)),
123    ("BST", Fixed(60)),
124    ("BTT", Fixed(360)),
125    ("CAST", Fixed(480)),
126    ("CAT", Fixed(120)),
127    ("CCT", Fixed(390)),
128    ("CDT", Fixed(-300)),
129    ("CEST", Fixed(120)),
130    ("timezoneOffsetDuringDst", Fixed(120)),
131    ("timezoneOffsetNonDst", Fixed(60)),
132    ("CHADT", Fixed(825)),
133    ("CHAST", Fixed(765)),
134    ("CKT", Fixed(-600)),
135    ("CLST", Fixed(-180)),
136    ("CLT", Fixed(-240)),
137    ("COT", Fixed(-300)),
138    ("CST", Fixed(-360)),
139    ("timezoneOffsetDuringDst", Fixed(-300)),
140    ("timezoneOffsetNonDst", Fixed(-360)),
141    ("CVT", Fixed(-60)),
142    ("CXT", Fixed(420)),
143    ("ChST", Fixed(600)),
144    ("DAVT", Fixed(420)),
145    ("EASST", Fixed(-300)),
146    ("EAST", Fixed(-360)),
147    ("EAT", Fixed(180)),
148    ("ECT", Fixed(-300)),
149    ("EDT", Fixed(-240)),
150    ("EEST", Fixed(180)),
151    ("EET", Fixed(120)),
152    ("EGST", Fixed(0)),
153    ("EGT", Fixed(-60)),
154    ("EST", Fixed(-300)),
155    ("timezoneOffsetDuringDst", Fixed(-240)),
156    ("timezoneOffsetNonDst", Fixed(-300)),
157    ("FJST", Fixed(780)),
158    ("FJT", Fixed(720)),
159    ("FKST", Fixed(-180)),
160    ("FKT", Fixed(-240)),
161    ("FNT", Fixed(-120)),
162    ("GALT", Fixed(-360)),
163    ("GAMT", Fixed(-540)),
164    ("GET", Fixed(240)),
165    ("GFT", Fixed(-180)),
166    ("GILT", Fixed(720)),
167    ("GMT", Fixed(0)),
168    ("GST", Fixed(240)),
169    ("GYT", Fixed(-240)),
170    ("HAA", Fixed(-180)),
171    ("HAC", Fixed(-300)),
172    ("HADT", Fixed(-540)),
173    ("HAE", Fixed(-240)),
174    ("HAP", Fixed(-420)),
175    ("HAR", Fixed(-360)),
176    ("HAST", Fixed(-600)),
177    ("HAT", Fixed(-90)),
178    ("HAY", Fixed(-480)),
179    ("HKT", Fixed(480)),
180    ("HLV", Fixed(-210)),
181    ("HNA", Fixed(-240)),
182    ("HNC", Fixed(-360)),
183    ("HNE", Fixed(-300)),
184    ("HNP", Fixed(-480)),
185    ("HNR", Fixed(-420)),
186    ("HNT", Fixed(-150)),
187    ("HNY", Fixed(-540)),
188    ("HOVT", Fixed(420)),
189    ("ICT", Fixed(420)),
190    ("IDT", Fixed(180)),
191    ("IOT", Fixed(360)),
192    ("IRDT", Fixed(270)),
193    ("IRKST", Fixed(540)),
194    ("IRKT", Fixed(540)),
195    ("IRST", Fixed(210)),
196    ("IST", Fixed(330)),
197    ("JST", Fixed(540)),
198    ("KGT", Fixed(360)),
199    ("KRAST", Fixed(480)),
200    ("KRAT", Fixed(480)),
201    ("KST", Fixed(540)),
202    ("KUYT", Fixed(240)),
203    ("LHDT", Fixed(660)),
204    ("LHST", Fixed(630)),
205    ("LINT", Fixed(840)),
206    ("MAGST", Fixed(720)),
207    ("MAGT", Fixed(720)),
208    ("MART", Fixed(-510)),
209    ("MAWT", Fixed(300)),
210    ("MDT", Fixed(-360)),
211    ("MESZ", Fixed(120)),
212    ("MEZ", Fixed(60)),
213    ("MHT", Fixed(720)),
214    ("MMT", Fixed(390)),
215    ("MSD", Fixed(240)),
216    ("MSK", Fixed(180)),
217    ("MST", Fixed(-420)),
218    ("timezoneOffsetDuringDst", Fixed(-360)),
219    ("timezoneOffsetNonDst", Fixed(-420)),
220    ("MUT", Fixed(240)),
221    ("MVT", Fixed(300)),
222    ("MYT", Fixed(480)),
223    ("NCT", Fixed(660)),
224    ("NDT", Fixed(-90)),
225    ("NFT", Fixed(690)),
226    ("NOVST", Fixed(420)),
227    ("NOVT", Fixed(360)),
228    ("NPT", Fixed(345)),
229    ("NST", Fixed(-150)),
230    ("NUT", Fixed(-660)),
231    ("NZDT", Fixed(780)),
232    ("NZST", Fixed(720)),
233    ("OMSST", Fixed(420)),
234    ("OMST", Fixed(420)),
235    ("PDT", Fixed(-420)),
236    ("PET", Fixed(-300)),
237    ("PETST", Fixed(720)),
238    ("PETT", Fixed(720)),
239    ("PGT", Fixed(600)),
240    ("PHOT", Fixed(780)),
241    ("PHT", Fixed(480)),
242    ("PKT", Fixed(300)),
243    ("PMDT", Fixed(-120)),
244    ("PMST", Fixed(-180)),
245    ("PONT", Fixed(660)),
246    ("PST", Fixed(-480)),
247    ("timezoneOffsetDuringDst", Fixed(-420)),
248    ("timezoneOffsetNonDst", Fixed(-480)),
249    ("PWT", Fixed(540)),
250    ("PYST", Fixed(-180)),
251    ("PYT", Fixed(-240)),
252    ("RET", Fixed(240)),
253    ("SAMT", Fixed(240)),
254    ("SAST", Fixed(120)),
255    ("SBT", Fixed(660)),
256    ("SCT", Fixed(240)),
257    ("SGT", Fixed(480)),
258    ("SRT", Fixed(-180)),
259    ("SST", Fixed(-660)),
260    ("TAHT", Fixed(-600)),
261    ("TFT", Fixed(300)),
262    ("TJT", Fixed(300)),
263    ("TKT", Fixed(780)),
264    ("TLT", Fixed(540)),
265    ("TMT", Fixed(300)),
266    ("TVT", Fixed(720)),
267    ("ULAT", Fixed(480)),
268    ("UTC", Fixed(0)),
269    ("UYST", Fixed(-120)),
270    ("UYT", Fixed(-180)),
271    ("UZT", Fixed(300)),
272    ("VET", Fixed(-210)),
273    ("VLAST", Fixed(660)),
274    ("VLAT", Fixed(660)),
275    ("VUT", Fixed(660)),
276    ("WAST", Fixed(120)),
277    ("WAT", Fixed(60)),
278    ("WEST", Fixed(60)),
279    ("WESZ", Fixed(60)),
280    ("WET", Fixed(0)),
281    ("WEZ", Fixed(0)),
282    ("WFT", Fixed(720)),
283    ("WGST", Fixed(-120)),
284    ("WGT", Fixed(-180)),
285    ("WIB", Fixed(420)),
286    ("WIT", Fixed(540)),
287    ("WITA", Fixed(480)),
288    ("WST", Fixed(780)),
289    ("WT", Fixed(0)),
290    ("YAKST", Fixed(600)),
291    ("YAKT", Fixed(600)),
292    ("YAPT", Fixed(600)),
293    ("YEKST", Fixed(360)),
294    ("YEKT", Fixed(360)),
295];
296
297pub fn lookup(abbr: &str) -> Option<TimezoneSpec> {
298    TIMEZONE_ABBR_MAP.iter().find(|(k, _)| *k == abbr).map(|(_, v)| *v)
299}
300
301/// `getNthWeekdayOfMonth(year, month, weekday, n, hour)`; month is 1–12.
302pub fn get_nth_weekday_of_month(year: i64, month: i64, weekday: i64, n: i64, hour: i64) -> JsDate {
303    let mut day_of_month = 0;
304    let mut i = 0;
305    while i < n {
306        day_of_month += 1;
307        if JsDate::local(year, month - 1, day_of_month, 0, 0, 0, 0).get_day() == weekday {
308            i += 1;
309        }
310    }
311    JsDate::local(year, month - 1, day_of_month, hour, 0, 0, 0)
312}
313
314/// `getLastWeekdayOfMonth(year, month, weekday, hour)`; month is 1–12.
315pub fn get_last_weekday_of_month(year: i64, month: i64, weekday: i64, hour: i64) -> JsDate {
316    let one_indexed_weekday = if weekday == 0 { 7 } else { weekday };
317    let mut date = JsDate::local(year, month - 1 + 1, 1, 12, 0, 0, 0);
318    let first_weekday_next_month = if date.get_day() == 0 { 7 } else { date.get_day() };
319    let day_diff = if first_weekday_next_month == one_indexed_weekday {
320        7
321    } else if first_weekday_next_month < one_indexed_weekday {
322        7 + first_weekday_next_month - one_indexed_weekday
323    } else {
324        first_weekday_next_month - one_indexed_weekday
325    };
326    date.set_date(date.get_date() - day_diff);
327    JsDate::local(year, month - 1, date.get_date(), hour, 0, 0, 0)
328}
329
330/// JS `a > b` on dates: false if either is NaN.
331fn date_gt(a: JsDate, b: JsDate) -> bool {
332    matches!((a.get_time(), b.get_time()), (Some(a), Some(b)) if a > b)
333}
334
335/// `toTimezoneOffset(timezoneInput, date, timezoneOverrides)`.
336pub fn to_timezone_offset(
337    input: Option<&TimezoneInput>,
338    date: Option<JsDate>,
339    overrides: &TimezoneAbbrMap,
340) -> Option<i64> {
341    let abbr = match input? {
342        TimezoneInput::Minutes(m) => return Some(*m),
343        TimezoneInput::Abbr(a) => a,
344    };
345    let spec = overrides.get(abbr).copied().or_else(|| lookup(abbr))?;
346    match spec {
347        Fixed(offset) => Some(offset),
348        TimezoneSpec::Ambiguous {
349            offset_during_dst,
350            offset_non_dst,
351            dst_start,
352            dst_end,
353        } => {
354            let date = date?;
355            let year = date.get_full_year();
356            if date_gt(date, dst_start(year)) && !date_gt(date, dst_end(year)) {
357                Some(offset_during_dst)
358            } else {
359                Some(offset_non_dst)
360            }
361        }
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn table_size_matches_upstream() {
371        // 198 fixed entries + 5 ambiguous ones in upstream/src/timezone.ts.
372        assert_eq!(TIMEZONE_ABBR_MAP.len(), 203);
373    }
374
375    #[test]
376    fn fixed_and_ambiguous() {
377        let none = TimezoneAbbrMap::new();
378        assert_eq!(
379            to_timezone_offset(Some(&TimezoneInput::Abbr("CDT".into())), None, &none),
380            Some(-300)
381        );
382        assert_eq!(
383            to_timezone_offset(Some(&TimezoneInput::Abbr("JST".into())), None, &none),
384            Some(540)
385        );
386        assert_eq!(
387            to_timezone_offset(Some(&TimezoneInput::Abbr("XYZ".into())), None, &none),
388            None
389        );
390        assert_eq!(
391            to_timezone_offset(Some(&TimezoneInput::Minutes(-90)), None, &none),
392            Some(-90)
393        );
394        let summer = JsDate::parse_iso("2021-07-01T00:00:00.000Z");
395        let winter = JsDate::parse_iso("2021-01-15T00:00:00.000Z");
396        assert_eq!(
397            to_timezone_offset(Some(&TimezoneInput::Abbr("ET".into())), summer, &none),
398            Some(-240)
399        );
400        assert_eq!(
401            to_timezone_offset(Some(&TimezoneInput::Abbr("ET".into())), winter, &none),
402            Some(-300)
403        );
404        assert_eq!(
405            to_timezone_offset(Some(&TimezoneInput::Abbr("ET".into())), None, &none),
406            None
407        );
408        let mut overrides = TimezoneAbbrMap::new();
409        overrides.insert("JST".into(), Fixed(1));
410        assert_eq!(
411            to_timezone_offset(Some(&TimezoneInput::Abbr("JST".into())), None, &overrides),
412            Some(1)
413        );
414    }
415
416    #[test]
417    fn dst_boundaries() {
418        // US DST 2021: Mar 14 02:00 to Nov 7 02:00; EU: Mar 28 02:00 to Oct 31 03:00.
419        assert_eq!(us_dst_start(2021).to_iso_string().unwrap(), "2021-03-14T02:00:00.000Z");
420        assert_eq!(us_dst_end(2021).to_iso_string().unwrap(), "2021-11-07T02:00:00.000Z");
421        assert_eq!(cet_dst_start(2021).to_iso_string().unwrap(), "2021-03-28T02:00:00.000Z");
422        assert_eq!(cet_dst_end(2021).to_iso_string().unwrap(), "2021-10-31T03:00:00.000Z");
423    }
424}