Skip to main content

splashboard/
time.rs

1//! Project-wide locale + timezone helpers. Every fetcher / renderer that emits a
2//! human-readable timestamp goes through here so the same `[general]` defaults
3//! (and per-widget overrides) drive every clock-shaped output instead of each
4//! site reaching for `Local::now()` / `Utc::now()` independently.
5//!
6//! - `now_in(tz)` resolves an IANA name (`"Asia/Tokyo"`) to a fixed-offset
7//!   `DateTime`. Falls back to `Local` for unknown names — never panics.
8//! - `today_in(tz)` is the date-only counterpart for "today" math.
9//! - `parse_locale(name)` resolves a `chrono::Locale` from `"en_US"` /
10//!   `"ja_JP"`. Falls back to `POSIX` (chrono's hard-coded English).
11//! - `format_local(dt, fmt, locale)` is `chrono::format_localized` with a
12//!   directive-error fallback so a typo'd `%Q` doesn't crash the splash.
13//! - `format_relative_compact(seconds, locale)` is the `3h` / `2d` /
14//!   `1w` rounding used by `list_timeline` and friends. Locale only flips
15//!   the suffix glyphs; the math is locale-independent.
16//!
17//! Behaviour with `tz = None` and `locale = None` matches the pre-migration
18//! `Local::now()` + `format(...)` output bit-for-bit (POSIX is what chrono's
19//! unlocalised `format` already emits), so call sites can migrate without
20//! having to compare snapshots.
21
22use chrono::{DateTime, FixedOffset, Local, Locale, NaiveDate, Utc};
23
24pub const DEFAULT_LOCALE: Locale = Locale::POSIX;
25
26pub fn now_in(timezone: Option<&str>) -> DateTime<FixedOffset> {
27    match parse_tz(timezone) {
28        Some(tz) => Utc::now().with_timezone(&tz).fixed_offset(),
29        None => Local::now().fixed_offset(),
30    }
31}
32
33pub fn today_in(timezone: Option<&str>) -> NaiveDate {
34    now_in(timezone).date_naive()
35}
36
37pub fn parse_tz(name: Option<&str>) -> Option<chrono_tz::Tz> {
38    name.and_then(|s| s.parse().ok())
39}
40
41pub fn parse_locale(name: Option<&str>) -> Locale {
42    name.and_then(|s| Locale::try_from(s).ok())
43        .unwrap_or(DEFAULT_LOCALE)
44}
45
46pub fn format_local<Tz>(dt: &DateTime<Tz>, fmt: &str, locale: Option<&str>) -> String
47where
48    Tz: chrono::TimeZone,
49    Tz::Offset: std::fmt::Display,
50{
51    let loc = parse_locale(locale);
52    format_with(dt, fmt, loc).unwrap_or_else(|| dt.format("%H:%M").to_string())
53}
54
55fn format_with<Tz>(dt: &DateTime<Tz>, fmt: &str, locale: Locale) -> Option<String>
56where
57    Tz: chrono::TimeZone,
58    Tz::Offset: std::fmt::Display,
59{
60    use std::fmt::Write;
61    let mut buf = String::new();
62    write!(&mut buf, "{}", dt.format_localized(fmt, locale)).ok()?;
63    Some(buf)
64}
65
66/// `3h` / `2d` / `1w` style label for `seconds_ago`. Future deltas grow an `in `
67/// prefix; sub-minute deltas collapse to `now`. Returns `None` when the delta
68/// exceeds ~4 weeks so callers can fall back to an absolute date.
69pub fn format_relative_compact(seconds_ago: i64, _locale: Option<&str>) -> Option<String> {
70    if seconds_ago.abs() < 45 {
71        return Some("now".into());
72    }
73    let abs = seconds_ago.abs();
74    let body = compact_body(abs)?;
75    Some(if seconds_ago >= 0 {
76        body
77    } else {
78        format!("in {body}")
79    })
80}
81
82fn compact_body(abs: i64) -> Option<String> {
83    const MIN: i64 = 60;
84    const HOUR: i64 = 60 * 60;
85    const DAY: i64 = 60 * 60 * 24;
86    const WEEK: i64 = DAY * 7;
87    if abs < HOUR {
88        Some(format!("{}m", (abs + MIN / 2) / MIN))
89    } else if abs < DAY {
90        Some(format!("{}h", abs / HOUR))
91    } else if abs < WEEK {
92        Some(format!("{}d", abs / DAY))
93    } else if abs < WEEK * 4 {
94        Some(format!("{}w", abs / WEEK))
95    } else {
96        None
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use chrono::TimeZone;
104
105    #[test]
106    fn now_in_invalid_tz_falls_back_to_local() {
107        let _ = now_in(Some("Not/AZone"));
108    }
109
110    #[test]
111    fn now_in_resolves_iana_name() {
112        let dt = now_in(Some("Asia/Tokyo"));
113        assert_eq!(dt.offset().local_minus_utc(), 9 * 3600);
114    }
115
116    #[test]
117    fn parse_locale_defaults_to_posix() {
118        assert_eq!(parse_locale(None), Locale::POSIX);
119        assert_eq!(parse_locale(Some("nonsense_XX")), Locale::POSIX);
120    }
121
122    #[test]
123    fn parse_locale_recognises_known_name() {
124        assert_eq!(parse_locale(Some("ja_JP")), Locale::ja_JP);
125    }
126
127    #[test]
128    fn format_local_invalid_directive_falls_back_to_safe_default() {
129        let dt = Utc.with_ymd_and_hms(2026, 4, 22, 12, 34, 0).unwrap();
130        assert_eq!(format_local(&dt, "%Q", None), "12:34");
131    }
132
133    #[test]
134    fn format_local_default_locale_matches_posix_format() {
135        let dt = Utc.with_ymd_and_hms(2026, 4, 22, 0, 0, 0).unwrap();
136        assert_eq!(format_local(&dt, "%b %d", None), "Apr 22");
137    }
138
139    #[test]
140    fn format_local_japanese_weekday_uses_locale_table() {
141        let dt = Utc.with_ymd_and_hms(2026, 4, 22, 0, 0, 0).unwrap();
142        assert_eq!(format_local(&dt, "%A", Some("ja_JP")), "水曜日");
143    }
144
145    #[test]
146    fn format_relative_compact_under_45_seconds_is_now() {
147        assert_eq!(format_relative_compact(30, None).as_deref(), Some("now"));
148    }
149
150    #[test]
151    fn format_relative_compact_minutes_round_half_up() {
152        assert_eq!(format_relative_compact(45, None).as_deref(), Some("1m"));
153        assert_eq!(format_relative_compact(120, None).as_deref(), Some("2m"));
154    }
155
156    #[test]
157    fn format_relative_compact_hours() {
158        assert_eq!(
159            format_relative_compact(3 * 3600, None).as_deref(),
160            Some("3h")
161        );
162    }
163
164    #[test]
165    fn format_relative_compact_future_grows_in_prefix() {
166        assert_eq!(
167            format_relative_compact(-3600, None).as_deref(),
168            Some("in 1h")
169        );
170    }
171
172    #[test]
173    fn format_relative_compact_returns_none_past_4_weeks() {
174        assert!(format_relative_compact(60 * 86_400, None).is_none());
175    }
176
177    #[test]
178    fn today_in_matches_now_in_date() {
179        let now = now_in(Some("UTC"));
180        assert_eq!(today_in(Some("UTC")), now.date_naive());
181    }
182}