Skip to main content

studio_worker/ui/
format.rs

1//! How the UI writes durations, ages, days and times.
2
3use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
4
5/// `118 ms`, `12s`, `3m 04s`, `1h 12m`.
6pub fn format_duration(d: chrono::Duration) -> String {
7    let millis = d.num_milliseconds().max(0);
8    if millis < 1000 {
9        return format!("{millis} ms");
10    }
11    let secs = d.num_seconds().max(0);
12    if secs < 60 {
13        return format!("{secs}s");
14    }
15    let mins = secs / 60;
16    if mins < 60 {
17        let rem = secs % 60;
18        return format!("{mins}m {rem:02}s");
19    }
20    let hours = mins / 60;
21    let rem_min = mins % 60;
22    format!("{hours}h {rem_min:02}m")
23}
24
25/// `5s ago`, `5m 12s ago`, `2h 05m ago`; a time ahead of `now` is `just now`.
26pub fn format_age(now: DateTime<Utc>, when: DateTime<Utc>) -> String {
27    let secs = now.signed_duration_since(when).num_seconds();
28    if secs < 0 {
29        return "just now".into();
30    }
31    if secs < 60 {
32        return format!("{secs}s ago");
33    }
34    let mins = secs / 60;
35    if mins < 60 {
36        let rem = secs % 60;
37        return format!("{mins}m {rem:02}s ago");
38    }
39    let hours = mins / 60;
40    let rem_min = mins % 60;
41    format!("{hours}h {rem_min:02}m ago")
42}
43
44/// The heading of a day of jobs: `Today`, `Yesterday`, `Mon 21 Sep`, or
45/// `21 Sep 2025` for another year.
46pub fn day_label(day: NaiveDate, today: NaiveDate) -> String {
47    if day == today {
48        "Today".into()
49    } else if Some(day) == today.pred_opt() {
50        "Yesterday".into()
51    } else if day.year() == today.year() {
52        day.format("%a %-d %b").to_string()
53    } else {
54        day.format("%-d %b %Y").to_string()
55    }
56}
57
58/// `when` as `HH:MM:SS` in `tz`.
59pub fn clock<Tz: TimeZone>(when: DateTime<Utc>, tz: &Tz) -> String
60where
61    Tz::Offset: std::fmt::Display,
62{
63    when.with_timezone(tz).format("%H:%M:%S").to_string()
64}
65
66/// `when` as `HH:MM:SS` when it is on `now`'s day in `tz`, else as
67/// `2026-01-02 03:04`.
68pub fn moment<Tz: TimeZone>(when: DateTime<Utc>, now: DateTime<Utc>, tz: &Tz) -> String
69where
70    Tz::Offset: std::fmt::Display,
71{
72    if when.with_timezone(tz).date_naive() == now.with_timezone(tz).date_naive() {
73        clock(when, tz)
74    } else {
75        when.with_timezone(tz).format("%Y-%m-%d %H:%M").to_string()
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn durations_cover_every_range() {
85        let s = chrono::Duration::seconds;
86        assert_eq!(
87            format_duration(chrono::Duration::milliseconds(118)),
88            "118 ms"
89        );
90        assert_eq!(format_duration(s(12)), "12s");
91        assert_eq!(format_duration(s(184)), "3m 04s");
92        assert_eq!(format_duration(s(3600 + 12 * 60)), "1h 12m");
93        assert_eq!(format_duration(s(-5)), "0 ms");
94    }
95
96    #[test]
97    fn ages_cover_every_range() {
98        let at = |h, m, s| Utc.with_ymd_and_hms(2026, 5, 25, h, m, s).unwrap();
99        assert_eq!(format_age(at(12, 0, 30), at(12, 0, 18)), "12s ago");
100        assert_eq!(format_age(at(12, 5, 30), at(12, 0, 18)), "5m 12s ago");
101        assert_eq!(format_age(at(14, 5, 0), at(12, 0, 0)), "2h 05m ago");
102        assert_eq!(format_age(at(12, 0, 0), at(12, 0, 5)), "just now");
103    }
104
105    #[test]
106    fn days_read_relative_to_today() {
107        let d = |y, m, day| NaiveDate::from_ymd_opt(y, m, day).unwrap();
108        let today = d(2026, 9, 26);
109        assert_eq!(day_label(today, today), "Today");
110        assert_eq!(day_label(d(2026, 9, 25), today), "Yesterday");
111        assert_eq!(day_label(d(2026, 9, 21), today), "Mon 21 Sep");
112        assert_eq!(day_label(d(2025, 12, 31), today), "31 Dec 2025");
113    }
114
115    #[test]
116    fn a_moment_is_a_clock_today_and_a_date_before() {
117        let now = Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).unwrap();
118        let today = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
119        let before = Utc.with_ymd_and_hms(2026, 1, 1, 23, 4, 5).unwrap();
120        assert_eq!(moment(today, now, &Utc), "03:04:05");
121        assert_eq!(moment(before, now, &Utc), "2026-01-01 23:04");
122    }
123
124    #[test]
125    fn times_read_in_the_given_zone() {
126        let when = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
127        assert_eq!(clock(when, &Utc), "03:04:05");
128        let plus_two = chrono::FixedOffset::east_opt(2 * 3600).unwrap();
129        assert_eq!(clock(when, &plus_two), "05:04:05");
130    }
131}