Skip to main content

teksilo_widgets/common/datetime/
locale.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-locale defaults: first day of week + segment-pattern.
5//!
6//! Hand-curated for the top ~25 locales, matching CLDR. Any locale not
7//! listed falls back to ISO 8601: Monday first day, `YYYY-MM-DD`. Both
8//! defaults are overridable on the widget builder.
9
10use jiff::civil::Weekday;
11
12/// Default first day of week for the given locale tag (e.g. `"en-US"`,
13/// `"fr-FR"`). Lookup is a longest-prefix match: a fully-qualified
14/// `"en-US"` resolves before the bare `"en"` family.
15pub fn first_day_of_week_for_locale(tag: &str) -> Weekday {
16    let normalized = tag.to_ascii_lowercase();
17
18    // Region-specific overrides first.
19    for &(prefix, weekday) in REGION_FIRST_DAY {
20        if normalized == prefix || normalized.starts_with(&format!("{prefix}-")) {
21            return weekday;
22        }
23    }
24    // Then language-only fallback.
25    for &(prefix, weekday) in LANG_FIRST_DAY {
26        if normalized == prefix || normalized.starts_with(&format!("{prefix}-")) {
27            return weekday;
28        }
29    }
30    // ISO 8601 default.
31    Weekday::Monday
32}
33
34/// Whether the given locale prefers a 12-hour clock (with AM/PM) for
35/// digital-UI time display. Returns `false` (24-hour) for any locale
36/// not explicitly listed — matching CLDR's behaviour for the vast
37/// majority of locales worldwide. The few `true` cases are the
38/// English-speaking regions where digital-UI 12-hour display is the
39/// near-universal convention: US, Canada, Australia, New Zealand,
40/// Philippines, plus the `en-IN`/`en-PK` digital convention. Lookup
41/// is longest-prefix match, identical to [`format_pattern_for_locale`]
42/// and [`first_day_of_week_for_locale`].
43pub fn prefers_12_hour_clock(tag: &str) -> bool {
44    let normalized = tag.to_ascii_lowercase();
45    for &prefix in REGION_12H {
46        if normalized == prefix || normalized.starts_with(&format!("{prefix}-")) {
47            return true;
48        }
49    }
50    false
51}
52
53/// Default date format pattern for the given locale tag. Uses the
54/// strftime subset described in `pattern.rs`.
55pub fn format_pattern_for_locale(tag: &str) -> &'static str {
56    let normalized = tag.to_ascii_lowercase();
57
58    for &(prefix, pat) in REGION_PATTERNS {
59        if normalized == prefix || normalized.starts_with(&format!("{prefix}-")) {
60            return pat;
61        }
62    }
63    for &(prefix, pat) in LANG_PATTERNS {
64        if normalized == prefix || normalized.starts_with(&format!("{prefix}-")) {
65            return pat;
66        }
67    }
68    // ISO 8601 fallback.
69    "%Y-%m-%d"
70}
71
72// ──────────────────────────────────────────────────────────────────
73// Tables. Region-specific entries take precedence over language-only
74// fallbacks so `en-US` resolves before the generic `en`. Lookup
75// strings must be lowercase and match the *prefix* of a normalized
76// locale tag.
77
78/// Per-region first-day-of-week. Ordered most-specific first; the
79/// resolver short-circuits on the first match.
80const REGION_FIRST_DAY: &[(&str, Weekday)] = &[
81    ("en-us", Weekday::Sunday),
82    ("en-ca", Weekday::Sunday),
83    ("en-au", Weekday::Sunday),
84    ("ja-jp", Weekday::Sunday),
85    ("zh-cn", Weekday::Sunday),
86    ("zh-tw", Weekday::Sunday),
87    ("ko-kr", Weekday::Sunday),
88    ("he-il", Weekday::Sunday),
89    ("ar-eg", Weekday::Saturday),
90    ("ar-sa", Weekday::Sunday),
91    ("ar-ae", Weekday::Saturday),
92    ("ar-dz", Weekday::Saturday),
93    ("ar-ma", Weekday::Saturday),
94    ("fa-ir", Weekday::Saturday),
95];
96
97/// Per-language first-day-of-week. Used when no region match was found.
98const LANG_FIRST_DAY: &[(&str, Weekday)] = &[
99    // Most of Western and Eastern Europe + global "Monday-first" group.
100    ("en", Weekday::Monday),
101    ("fr", Weekday::Monday),
102    ("de", Weekday::Monday),
103    ("es", Weekday::Monday),
104    ("it", Weekday::Monday),
105    ("pt", Weekday::Monday),
106    ("nl", Weekday::Monday),
107    ("sv", Weekday::Monday),
108    ("nb", Weekday::Monday),
109    ("nn", Weekday::Monday),
110    ("da", Weekday::Monday),
111    ("fi", Weekday::Monday),
112    ("pl", Weekday::Monday),
113    ("ru", Weekday::Monday),
114    ("uk", Weekday::Monday),
115    ("cs", Weekday::Monday),
116    ("sk", Weekday::Monday),
117    ("hu", Weekday::Monday),
118    ("ro", Weekday::Monday),
119    ("bg", Weekday::Monday),
120    ("hr", Weekday::Monday),
121    ("sr", Weekday::Monday),
122    ("sl", Weekday::Monday),
123    ("el", Weekday::Monday),
124    ("tr", Weekday::Monday),
125];
126
127/// Region prefixes that prefer a 12-hour clock for digital UI.
128/// Anything not listed defaults to 24-hour.
129const REGION_12H: &[&str] = &[
130    "en-us", "en-ca", "en-au", "en-nz", "en-ph", "en-in", "en-pk",
131];
132
133/// Per-region default pattern. Ordered most-specific first.
134const REGION_PATTERNS: &[(&str, &str)] = &[
135    ("en-us", "%m/%d/%Y"),
136    ("en-ca", "%Y-%m-%d"),
137    ("en-gb", "%d/%m/%Y"),
138    ("en-au", "%d/%m/%Y"),
139    ("en-ie", "%d/%m/%Y"),
140    ("en-nz", "%d/%m/%Y"),
141    ("ja-jp", "%Y/%m/%d"),
142    ("zh-cn", "%Y/%m/%d"),
143    ("zh-tw", "%Y/%m/%d"),
144    ("ko-kr", "%Y. %m. %d."),
145    ("de-de", "%d.%m.%Y"),
146    ("de-at", "%d.%m.%Y"),
147    ("de-ch", "%d.%m.%Y"),
148    ("fr-ca", "%Y-%m-%d"),
149];
150
151/// Per-language default pattern. Used when no region match was found.
152const LANG_PATTERNS: &[(&str, &str)] = &[
153    ("en", "%Y-%m-%d"),
154    ("fr", "%d/%m/%Y"),
155    ("es", "%d/%m/%Y"),
156    ("it", "%d/%m/%Y"),
157    ("pt", "%d/%m/%Y"),
158    ("nl", "%d-%m-%Y"),
159    ("de", "%d.%m.%Y"),
160    ("sv", "%Y-%m-%d"),
161    ("nb", "%d.%m.%Y"),
162    ("nn", "%d.%m.%Y"),
163    ("da", "%d-%m-%Y"),
164    ("fi", "%d.%m.%Y"),
165    ("pl", "%d.%m.%Y"),
166    ("ru", "%d.%m.%Y"),
167    ("uk", "%d.%m.%Y"),
168    ("cs", "%d.%m.%Y"),
169    ("sk", "%d.%m.%Y"),
170    ("hu", "%Y. %m. %d."),
171    ("ro", "%d.%m.%Y"),
172    ("bg", "%d.%m.%Y"),
173    ("hr", "%d.%m.%Y"),
174    ("sr", "%d.%m.%Y"),
175    ("sl", "%d.%m.%Y"),
176    ("el", "%d/%m/%Y"),
177    ("tr", "%d.%m.%Y"),
178    ("ja", "%Y/%m/%d"),
179    ("zh", "%Y/%m/%d"),
180    ("ko", "%Y. %m. %d."),
181    ("ar", "%d/%m/%Y"),
182    ("he", "%d/%m/%Y"),
183    ("fa", "%Y/%m/%d"),
184];
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn first_day_us_is_sunday_eu_is_monday() {
192        assert_eq!(first_day_of_week_for_locale("en-US"), Weekday::Sunday);
193        assert_eq!(first_day_of_week_for_locale("fr-FR"), Weekday::Monday);
194        assert_eq!(first_day_of_week_for_locale("de-DE"), Weekday::Monday);
195        assert_eq!(first_day_of_week_for_locale("ja-JP"), Weekday::Sunday);
196    }
197
198    #[test]
199    fn first_day_unknown_locale_falls_back_to_iso() {
200        assert_eq!(first_day_of_week_for_locale("xx-XX"), Weekday::Monday);
201    }
202
203    #[test]
204    fn pattern_us_is_mdy_eu_is_dmy() {
205        assert_eq!(format_pattern_for_locale("en-US"), "%m/%d/%Y");
206        assert_eq!(format_pattern_for_locale("en-GB"), "%d/%m/%Y");
207        assert_eq!(format_pattern_for_locale("fr-FR"), "%d/%m/%Y");
208        assert_eq!(format_pattern_for_locale("de-DE"), "%d.%m.%Y");
209        assert_eq!(format_pattern_for_locale("ja-JP"), "%Y/%m/%d");
210        assert_eq!(format_pattern_for_locale("sv-SE"), "%Y-%m-%d");
211    }
212
213    #[test]
214    fn region_overrides_language() {
215        // `en` defaults to ISO; `en-US` overrides to MDY.
216        assert_eq!(format_pattern_for_locale("en"), "%Y-%m-%d");
217        assert_eq!(format_pattern_for_locale("en-US"), "%m/%d/%Y");
218    }
219
220    #[test]
221    fn time_format_us_australia_are_12h() {
222        assert!(prefers_12_hour_clock("en-US"));
223        assert!(prefers_12_hour_clock("en-AU"));
224        assert!(prefers_12_hour_clock("en-CA"));
225        assert!(prefers_12_hour_clock("en-PH"));
226    }
227
228    #[test]
229    fn time_format_eu_asia_are_24h() {
230        assert!(!prefers_12_hour_clock("fr-FR"));
231        assert!(!prefers_12_hour_clock("de-DE"));
232        assert!(!prefers_12_hour_clock("ja-JP"));
233        assert!(!prefers_12_hour_clock("en-GB"));
234        assert!(!prefers_12_hour_clock("zh-CN"));
235    }
236
237    #[test]
238    fn time_format_unknown_locale_defaults_to_24h() {
239        assert!(!prefers_12_hour_clock("xx-XX"));
240        assert!(!prefers_12_hour_clock(""));
241    }
242}