Skip to main content

day_localization/
datetime.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Locale-aware dates and times outside a message (docs/localization.md "Dates outside a
5//! message"), backed by the same icu4x formatter as the Fluent `DATETIME()` builtin.
6//!
7//! A chart's time axis, a table's date column and a "last updated" readout all need a date in the
8//! reader's own form (`Mar 5` in English, `5 mars` in French, `05.03.` in German, `03/05` in
9//! Japanese, and `3:30 PM` against `15:30` for the clock) with no message to hang it on. Assembled
10//! by hand from an English month list it is wrong in most locales, in the words, in the order,
11//! and in the hour cycle.
12//!
13//! [`DateFields`] names *which* parts to show, which is the decision the caller actually has (an
14//! axis spanning years shows years, one spanning hours shows clock times); the locale decides how
15//! they are written. Formatters are cached per (locale, fields) in a thread-local; any failure
16//! degrades to an ISO-8601 rendering rather than an error or a blank.
17
18use std::cell::RefCell;
19use std::collections::HashMap;
20
21use icu_calendar::cal::Gregorian;
22use icu_datetime::FixedCalendarDateTimeFormatter;
23use icu_datetime::fieldsets::enums::CompositeDateTimeFieldSet;
24
25/// Which parts of an instant to write. The locale decides their order, words and punctuation.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum DateFields {
28    /// `2026`.
29    Year,
30    /// `Mar 2026` · `mars 2026` · `03/2026` · `2026/03`.
31    YearMonth,
32    /// `Mar` · `mars` · `Mär` · `3月`: a month on its own, for a column of months (a calendar heat
33    /// map's header) whose years are elsewhere.
34    Month,
35    /// `Mar 5` · `5 mars` · `05.03.` · `03/05`.
36    MonthDay,
37    /// `Mar 5, 2026` · `5 mars 2026` · `05.03.2026` · `2026/03/05`.
38    YearMonthDay,
39    /// `3:30 PM` · `15:30`: the clock in the locale's own hour cycle.
40    HourMinute,
41    /// `3:30:15 PM` · `15:30:15`.
42    HourMinuteSecond,
43}
44
45type Formatter = FixedCalendarDateTimeFormatter<Gregorian, CompositeDateTimeFieldSet>;
46
47day_reactive::tls_slots! {
48    datetime;
49    /// `None` = construction failed for that locale (negative-cached to avoid re-trying per call).
50    static FORMATTERS: RefCell<HashMap<(String, DateFields), Option<Formatter>>> =
51        RefCell::new(HashMap::new());
52}
53
54fn build(locale: &str, fields: DateFields) -> Option<Formatter> {
55    use icu_datetime::fieldsets::builder::{DateFields as Icu, FieldSetBuilder};
56    use icu_datetime::options::{Length, TimePrecision};
57
58    let parsed: icu_locale_core::Locale = locale.parse().ok()?;
59    let mut b = FieldSetBuilder::new();
60    // Medium: the locale's medium date form, an abbreviated month name where the locale writes
61    // one (`Mar 5`, `5 mars`) and its own numeric form where it does not (`05.03.`, `03/05`).
62    // Long would spell English months out in full, too wide for an axis; short would turn
63    // `Mar 5` into the ambiguous `3/5`.
64    b.length = Some(Length::Medium);
65    match fields {
66        DateFields::Year => b.date_fields = Some(Icu::Y),
67        DateFields::YearMonth => b.date_fields = Some(Icu::YM),
68        DateFields::Month => b.date_fields = Some(Icu::M),
69        DateFields::MonthDay => b.date_fields = Some(Icu::MD),
70        DateFields::YearMonthDay => b.date_fields = Some(Icu::YMD),
71        DateFields::HourMinute => b.time_precision = Some(TimePrecision::Minute),
72        DateFields::HourMinuteSecond => b.time_precision = Some(TimePrecision::Second),
73    }
74    let field_set = b.build_composite_datetime().ok()?;
75    Formatter::try_new((&parsed).into(), field_set).ok()
76}
77
78/// The ISO-8601 rendering of the requested fields, for every failure path.
79fn iso(fields: DateFields, (y, m, d): (i32, u8, u8), (hh, mm, ss): (u8, u8, u8)) -> String {
80    match fields {
81        DateFields::Year => format!("{y:04}"),
82        DateFields::YearMonth => format!("{y:04}-{m:02}"),
83        DateFields::Month => format!("{m:02}"),
84        DateFields::MonthDay => format!("{m:02}-{d:02}"),
85        DateFields::YearMonthDay => format!("{y:04}-{m:02}-{d:02}"),
86        DateFields::HourMinute => format!("{hh:02}:{mm:02}"),
87        DateFields::HourMinuteSecond => format!("{hh:02}:{mm:02}:{ss:02}"),
88    }
89}
90
91/// Write the instant `epoch_seconds` (seconds since 1970-01-01T00:00:00Z, read as UTC civil
92/// time, the convention of the Fluent `DATETIME()` builtin and of chart time axes) in `locale`,
93/// showing `fields` (untracked).
94pub fn format_date_in(locale: &str, epoch_seconds: f64, fields: DateFields) -> String {
95    if !epoch_seconds.is_finite() {
96        return format!("{epoch_seconds}");
97    }
98    let (Some(date), Some(time)) = crate::intl::from_epoch_seconds(epoch_seconds.floor() as i64)
99    else {
100        return format!("{epoch_seconds}");
101    };
102    let (Ok(d), Ok(t)) = (
103        icu_calendar::Date::try_new_gregorian(date.0, date.1, date.2),
104        icu_time::Time::try_new(time.0, time.1, time.2, 0),
105    ) else {
106        return iso(fields, date, time);
107    };
108    FORMATTERS.with(|c| {
109        let mut map = c.borrow_mut();
110        let formatter = map
111            .entry((locale.to_string(), fields))
112            .or_insert_with(|| build(locale, fields));
113        match formatter {
114            Some(f) => f
115                .format(&icu_time::DateTime { date: d, time: t })
116                .to_string(),
117            None => iso(fields, date, time),
118        }
119    })
120}
121
122/// [`format_date_in`] in the current locale (tracked, like [`crate::format_decimal`]), so a label
123/// inside a reactive closure re-renders when the locale switches.
124pub fn format_date(epoch_seconds: f64, fields: DateFields) -> String {
125    let locale = crate::locale().get();
126    format_date_in(&locale, epoch_seconds, fields)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// 2026-03-05T15:30:15Z.
134    const T: f64 = 1_772_724_615.0;
135
136    fn f(locale: &str, fields: DateFields) -> String {
137        // The exact spaces (a no-break space before `PM`, a narrow one in French) are CLDR's to
138        // choose; the assertions compare with them folded to plain spaces.
139        format_date_in(locale, T, fields).replace(['\u{a0}', '\u{202f}'], " ")
140    }
141
142    #[test]
143    fn a_month_and_day_are_written_the_locales_way() {
144        assert_eq!(f("en", DateFields::MonthDay), "Mar 5");
145        assert_eq!(f("fr", DateFields::MonthDay), "5 mars");
146        // German's and Japanese medium dates are numeric in CLDR: the locale's own form.
147        assert_eq!(f("de", DateFields::MonthDay), "05.03.");
148        assert_eq!(f("ja", DateFields::MonthDay), "03/05");
149    }
150
151    #[test]
152    fn a_month_and_year_are_written_the_locales_way() {
153        assert_eq!(f("en", DateFields::YearMonth), "Mar 2026");
154        assert_eq!(f("fr", DateFields::YearMonth), "mars 2026");
155        assert_eq!(f("de", DateFields::YearMonth), "03/2026");
156        assert_eq!(f("ja", DateFields::YearMonth), "2026/03");
157        assert_eq!(f("en", DateFields::Year), "2026");
158        assert_eq!(f("ja", DateFields::Year), "2026年");
159    }
160
161    #[test]
162    fn a_month_alone_is_the_locales_abbreviated_name() {
163        assert_eq!(f("en", DateFields::Month), "Mar");
164        assert_eq!(f("fr", DateFields::Month), "mars");
165        assert_eq!(f("de", DateFields::Month), "Mär");
166        assert_eq!(f("ja", DateFields::Month), "3月");
167        assert_eq!(f("ru", DateFields::Month), "март");
168    }
169
170    #[test]
171    fn a_full_date_follows_the_locales_order() {
172        assert_eq!(f("en", DateFields::YearMonthDay), "Mar 5, 2026");
173        assert_eq!(f("fr", DateFields::YearMonthDay), "5 mars 2026");
174        assert_eq!(f("de", DateFields::YearMonthDay), "05.03.2026");
175    }
176
177    #[test]
178    fn the_clock_keeps_the_locales_hour_cycle() {
179        assert_eq!(f("en", DateFields::HourMinute), "3:30 PM");
180        assert_eq!(f("fr", DateFields::HourMinute), "15:30");
181        assert_eq!(f("de", DateFields::HourMinute), "15:30");
182        assert_eq!(f("en", DateFields::HourMinuteSecond), "3:30:15 PM");
183        assert_eq!(f("fr", DateFields::HourMinuteSecond), "15:30:15");
184    }
185
186    #[test]
187    fn an_instant_before_the_epoch_and_a_non_finite_one_degrade_sensibly() {
188        // 1969-12-31T23:00:00Z: the day before the epoch, not the epoch's own day.
189        assert_eq!(
190            format_date_in("en", -3600.0, DateFields::YearMonthDay),
191            "Dec 31, 1969"
192        );
193        assert_eq!(format_date_in("en", f64::NAN, DateFields::Year), "NaN");
194    }
195
196    #[test]
197    fn an_unparseable_locale_falls_back_to_iso() {
198        assert_eq!(
199            format_date_in("", T, DateFields::YearMonthDay),
200            "2026-03-05"
201        );
202        assert_eq!(format_date_in("", T, DateFields::HourMinute), "15:30");
203    }
204
205    #[test]
206    fn the_tracked_form_follows_the_current_locale() {
207        crate::set_locale("fr");
208        assert_eq!(
209            format_date(T, DateFields::MonthDay).replace('\u{a0}', " "),
210            "5 mars"
211        );
212        crate::set_locale("en");
213        assert_eq!(format_date(T, DateFields::MonthDay), "Mar 5");
214    }
215}