1use time::{Duration, OffsetDateTime};
4
5pub fn thousands(n: impl Into<i64>) -> String {
7 let n: i64 = n.into();
8 let digits = n.unsigned_abs().to_string();
9 let mut out = String::with_capacity(digits.len() + digits.len() / 3 + 1);
10 if n < 0 {
11 out.push('-');
12 }
13 for (i, c) in digits.chars().enumerate() {
14 if i > 0 && (digits.len() - i).is_multiple_of(3) {
15 out.push(',');
16 }
17 out.push(c);
18 }
19 out
20}
21
22pub fn ago(at: OffsetDateTime) -> String {
24 ago_from(at, OffsetDateTime::now_utc())
25}
26
27pub fn ago_from(at: OffsetDateTime, now: OffsetDateTime) -> String {
28 let secs = (now - at).whole_seconds();
29 match secs {
30 s if s < 45 => "just now".to_owned(),
31 s if s < 3600 => format!("{}m ago", (s + 30) / 60),
32 s if s < 86_400 => format!("{}h ago", s / 3600),
33 s if s < 7 * 86_400 => format!("{}d ago", s / 86_400),
34 _ => date(at),
35 }
36}
37
38pub fn date(at: OffsetDateTime) -> String {
40 const MONTHS: [&str; 12] = [
41 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
42 ];
43 format!("{} {}", MONTHS[at.month() as usize - 1], at.day())
44}
45
46pub fn datetime(at: OffsetDateTime) -> String {
48 format!("{}, {:02}:{:02} UTC", date(at), at.hour(), at.minute())
49}
50
51pub fn duration(d: Duration) -> String {
53 let s = d.whole_seconds().max(0);
54 match s {
55 s if s < 60 => format!("{s}s"),
56 s if s < 3600 => format!("{}m {:02}s", s / 60, s % 60),
57 s => format!("{}h {:02}m", s / 3600, (s % 3600) / 60),
58 }
59}
60
61pub fn millis(ms: u64) -> String {
63 duration(Duration::milliseconds(ms.min(i64::MAX as u64) as i64))
64}
65
66pub fn pct1(part: u64, whole: u64) -> String {
68 if whole == 0 {
69 return "0".to_owned();
70 }
71 let tenths = (part * 1000 + whole / 2) / whole;
72 format!("{}.{}", tenths / 10, tenths % 10)
73}
74
75pub fn percent(part: i64, whole: i64) -> String {
77 let p = pct1(part.max(0) as u64, whole.max(0) as u64);
78 format!("{}%", p.strip_suffix(".0").unwrap_or(&p))
79}
80
81pub fn pages(n: u32) -> String {
83 format!("{} page{}", thousands(n), if n == 1 { "" } else { "s" })
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn thousands_separators() {
92 assert_eq!(thousands(0), "0");
93 assert_eq!(thousands(999), "999");
94 assert_eq!(thousands(1284), "1,284");
95 assert_eq!(thousands(1_234_567), "1,234,567");
96 assert_eq!(thousands(-50_000), "-50,000");
97 }
98
99 #[test]
100 fn ago_buckets() {
101 let now = OffsetDateTime::UNIX_EPOCH + Duration::days(400);
102 assert_eq!(ago_from(now - Duration::seconds(10), now), "just now");
103 assert_eq!(ago_from(now - Duration::minutes(12), now), "12m ago");
104 assert_eq!(ago_from(now - Duration::hours(3), now), "3h ago");
105 assert_eq!(ago_from(now - Duration::days(2), now), "2d ago");
106 assert!(ago_from(now - Duration::days(30), now).contains(' '));
107 }
108
109 #[test]
110 fn durations() {
111 assert_eq!(duration(Duration::seconds(42)), "42s");
112 assert_eq!(duration(Duration::seconds(102)), "1m 42s");
113 assert_eq!(duration(Duration::seconds(7500)), "2h 05m");
114 assert_eq!(pct1(1158, 1284), "90.2");
115 assert_eq!(percent(2, 3), "66.7%");
116 assert_eq!(percent(5, 5), "100%");
117 assert_eq!(percent(0, 0), "0%");
118 assert_eq!(pages(1), "1 page");
119 assert_eq!(pages(1284), "1,284 pages");
120 assert_eq!(pct1(1, 0), "0");
121 }
122}