1use std::borrow::Cow;
2use std::time::{Duration, Instant};
3
4const MINUTE: u64 = 60;
5const HOUR: u64 = 60 * MINUTE;
6const DAY: u64 = 24 * HOUR;
7const YEAR: u64 = 365 * DAY;
8
9type Word = Cow<'static, str>;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct Units {
14 pub second: Word,
15 pub minute: Word,
16 pub hour: Word,
17 pub day: Word,
18 pub year: Word,
19 pub under: Word,
20 pub now: Word,
21}
22
23impl Units {
24 pub fn new(
25 second: impl Into<Word>,
26 minute: impl Into<Word>,
27 hour: impl Into<Word>,
28 day: impl Into<Word>,
29 ) -> Self {
30 Units {
31 second: second.into(),
32 minute: minute.into(),
33 hour: hour.into(),
34 day: day.into(),
35 year: Word::Borrowed(""),
36 under: Word::Borrowed(""),
37 now: Word::Borrowed(""),
38 }
39 }
40
41 pub fn year(mut self, word: impl Into<Word>) -> Self {
42 self.year = word.into();
43 self
44 }
45
46 pub fn under(mut self, words: impl Into<Word>) -> Self {
47 self.under = words.into();
48 self
49 }
50
51 pub fn now(mut self, words: impl Into<Word>) -> Self {
52 self.now = words.into();
53 self
54 }
55}
56
57pub fn span(took: Duration, units: &Units) -> String {
58 spanned(took, units, true)
59}
60
61pub fn span_hours(took: Duration, units: &Units) -> String {
62 spanned(took, units, false)
63}
64
65fn spanned(took: Duration, units: &Units, days: bool) -> String {
66 let seconds = took.as_secs();
67 if seconds == 0 && !units.under.is_empty() {
68 return units.under.to_string();
69 }
70 if seconds < MINUTE {
71 return format!("{seconds}{}", units.second);
72 }
73 let (big, big_unit, small, small_unit) = if seconds < HOUR {
74 (
75 seconds / MINUTE,
76 &units.minute,
77 seconds % MINUTE,
78 &units.second,
79 )
80 } else if seconds < DAY || !days {
81 (
82 seconds / HOUR,
83 &units.hour,
84 seconds % HOUR / MINUTE,
85 &units.minute,
86 )
87 } else {
88 (seconds / DAY, &units.day, seconds % DAY / HOUR, &units.hour)
89 };
90 format!("{big}{big_unit} {small}{small_unit}")
91}
92
93pub fn age(gone: Duration, units: &Units) -> String {
94 let seconds = gone.as_secs();
95 if seconds < MINUTE && !units.now.is_empty() {
96 units.now.to_string()
97 } else if seconds < MINUTE {
98 format!("{seconds}{}", units.second)
99 } else if seconds < HOUR {
100 format!("{}{}", seconds / MINUTE, units.minute)
101 } else if seconds < 2 * DAY {
102 format!("{}{}", seconds / HOUR, units.hour)
103 } else if seconds < YEAR || units.year.is_empty() {
104 format!("{}{}", seconds / DAY, units.day)
105 } else {
106 format!("{}{}", seconds / YEAR, units.year)
107 }
108}
109
110fn next(since: Instant, now: Instant, step: u64) -> Instant {
111 let gone = now.saturating_duration_since(since).as_secs();
112 since + Duration::from_secs((gone / step + 1) * step)
113}
114
115pub fn next_span(since: Instant, now: Instant) -> Instant {
116 let gone = now.saturating_duration_since(since).as_secs();
117 let step = if gone < HOUR { 1 } else { MINUTE };
118 next(since, now, step)
119}
120
121pub fn next_age(since: Instant, now: Instant) -> Instant {
122 let gone = now.saturating_duration_since(since).as_secs();
123 let step = if gone < MINUTE {
124 1
125 } else if gone < HOUR {
126 MINUTE
127 } else if gone < 2 * DAY {
128 HOUR
129 } else {
130 DAY
131 };
132 next(since, now, step)
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn spans_and_ages_take_the_apps_words_at_each_end() {
141 let units = Units::new("s", "m", "h", "d")
142 .year(" y")
143 .under("under 1s")
144 .now("just now");
145 let secs = Duration::from_secs;
146 assert_eq!(span(Duration::from_millis(400), &units), "under 1s");
147 assert_eq!(span(secs(62), &units), "1m 2s");
148 assert_eq!(span_hours(secs(49 * 3600 + 180), &units), "49h 3m");
149 assert_eq!(span(secs(49 * 3600 + 180), &units), "2d 1h");
150 assert_eq!(age(secs(59), &units), "just now");
151 assert_eq!(age(secs(3 * 86_400), &units), "3d");
152 assert_eq!(age(secs(800 * 86_400), &units), "2 y");
153 let plain = Units::new("s", "m", "h", "d");
154 assert_eq!(span(Duration::ZERO, &plain), "0s");
155 assert_eq!(age(secs(800 * 86_400), &plain), "800d");
156 }
157}