1use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
4
5pub 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
25pub 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
44pub 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
58pub 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
66pub 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}