Skip to main content

prosaic_core/
time.rs

1//! Time-aware framing helpers.
2//!
3//! Convert a signed duration (in seconds) — typically `now - timestamp`
4//! — into a natural English phrase like `"yesterday"`, `"3 weeks ago"`,
5//! or `"in 2 months"`. Language-agnostic math, English-flavoured
6//! wording; move to the `Language` trait if we grow multilingual.
7//!
8//! The surface is purely functional: callers compute a diff and pass
9//! it in. The engine stores a reference time (default: `SystemTime::now()`)
10//! and feeds the `{timestamp|relative}` pipe from it.
11
12const MINUTE: i64 = 60;
13const HOUR: i64 = 60 * MINUTE;
14const DAY: i64 = 24 * HOUR;
15const WEEK: i64 = 7 * DAY;
16const MONTH: i64 = 30 * DAY; // approximate — fine for relative framing
17const YEAR: i64 = 365 * DAY;
18
19/// Format a positive-is-past difference in seconds as a natural English
20/// phrase. Positive values mean the target is in the past (`"yesterday"`),
21/// negative values mean the future (`"tomorrow"`), zero means right now.
22pub fn format_relative(diff_secs: i64) -> String {
23    let past = diff_secs >= 0;
24    let abs = diff_secs.abs();
25
26    // Very recent: "just now" covers ~45 seconds in either direction.
27    if abs < 45 {
28        return if past {
29            "just now".to_string()
30        } else {
31            "any moment now".to_string()
32        };
33    }
34
35    // Minutes
36    if abs < HOUR {
37        let n = (abs + MINUTE / 2) / MINUTE;
38        let n = n.max(1);
39        return phrase(
40            past,
41            &format!("{n} minute{s} ago", s = s(n)),
42            &format!("in {n} minute{s}", s = s(n)),
43        );
44    }
45
46    // Hours
47    if abs < DAY {
48        let n = (abs + HOUR / 2) / HOUR;
49        let n = n.max(1);
50        return phrase(
51            past,
52            &match n {
53                1 => "an hour ago".to_string(),
54                _ => format!("{n} hours ago"),
55            },
56            &match n {
57                1 => "in an hour".to_string(),
58                _ => format!("in {n} hours"),
59            },
60        );
61    }
62
63    // Yesterday / tomorrow
64    if abs < 2 * DAY {
65        return phrase(past, "yesterday", "tomorrow");
66    }
67
68    // Days
69    if abs < WEEK {
70        let n = abs / DAY;
71        return phrase(past, &format!("{n} days ago"), &format!("in {n} days"));
72    }
73
74    // Last/next week
75    if abs < 2 * WEEK {
76        return phrase(past, "last week", "next week");
77    }
78
79    // Weeks
80    if abs < MONTH {
81        let n = abs / WEEK;
82        return phrase(past, &format!("{n} weeks ago"), &format!("in {n} weeks"));
83    }
84
85    // Last/next month
86    if abs < 2 * MONTH {
87        return phrase(past, "last month", "next month");
88    }
89
90    // Months
91    if abs < YEAR {
92        let n = abs / MONTH;
93        return phrase(past, &format!("{n} months ago"), &format!("in {n} months"));
94    }
95
96    // Last/next year
97    if abs < 2 * YEAR {
98        return phrase(past, "last year", "next year");
99    }
100
101    // Years
102    let n = abs / YEAR;
103    phrase(past, &format!("{n} years ago"), &format!("in {n} years"))
104}
105
106/// Format a **positive-is-later** inter-event delta in seconds as a
107/// narrative inter-event phrase.
108///
109/// Use when you want "the next day" / "moments later" style prose rather
110/// than "3 days ago" style absolute relative phrases.
111///
112/// Zero or negative input (same moment or earlier) returns
113/// `"at the same time"` — the caller is responsible for ordering.
114pub fn format_since_last(diff_secs: i64) -> String {
115    if diff_secs <= 0 {
116        return "at the same time".to_string();
117    }
118
119    if diff_secs < 60 {
120        return "moments later".to_string();
121    }
122
123    if diff_secs < HOUR {
124        let n = (diff_secs + MINUTE / 2) / MINUTE;
125        let n = n.max(1);
126        return match n {
127            1 => "a minute later".to_string(),
128            _ => format!("{n} minutes later"),
129        };
130    }
131
132    if diff_secs < DAY {
133        let n = (diff_secs + HOUR / 2) / HOUR;
134        let n = n.max(1);
135        // Below 6h → "N hours later", 6h..DAY → "later that day".
136        if n < 6 {
137            return match n {
138                1 => "an hour later".to_string(),
139                _ => format!("{n} hours later"),
140            };
141        }
142        return "later that day".to_string();
143    }
144
145    if diff_secs < 2 * DAY {
146        return "the next day".to_string();
147    }
148
149    if diff_secs < WEEK {
150        let n = diff_secs / DAY;
151        return format!("{n} days later");
152    }
153
154    if diff_secs < 2 * WEEK {
155        return "the following week".to_string();
156    }
157
158    if diff_secs < MONTH {
159        let n = diff_secs / WEEK;
160        return format!("{n} weeks later");
161    }
162
163    if diff_secs < 2 * MONTH {
164        return "the following month".to_string();
165    }
166
167    if diff_secs < YEAR {
168        let n = diff_secs / MONTH;
169        return format!("{n} months later");
170    }
171
172    if diff_secs < 2 * YEAR {
173        return "the following year".to_string();
174    }
175
176    let n = diff_secs / YEAR;
177    format!("{n} years later")
178}
179
180fn s(n: i64) -> &'static str {
181    if n == 1 { "" } else { "s" }
182}
183
184fn phrase(past: bool, past_form: &str, future_form: &str) -> String {
185    if past {
186        past_form.to_string()
187    } else {
188        future_form.to_string()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn just_now_for_small_past_and_future() {
198        assert_eq!(format_relative(0), "just now");
199        assert_eq!(format_relative(30), "just now");
200        assert_eq!(format_relative(-30), "any moment now");
201    }
202
203    #[test]
204    fn minutes() {
205        assert_eq!(format_relative(60), "1 minute ago");
206        assert_eq!(format_relative(300), "5 minutes ago");
207        assert_eq!(format_relative(-600), "in 10 minutes");
208    }
209
210    #[test]
211    fn hours() {
212        assert_eq!(format_relative(3600), "an hour ago");
213        assert_eq!(format_relative(3 * 3600), "3 hours ago");
214        assert_eq!(format_relative(-3600), "in an hour");
215    }
216
217    #[test]
218    fn yesterday_and_tomorrow() {
219        assert_eq!(format_relative(DAY + 3600), "yesterday");
220        assert_eq!(format_relative(-(DAY + 3600)), "tomorrow");
221    }
222
223    #[test]
224    fn days() {
225        assert_eq!(format_relative(3 * DAY), "3 days ago");
226        assert_eq!(format_relative(-5 * DAY), "in 5 days");
227    }
228
229    #[test]
230    fn last_week() {
231        assert_eq!(format_relative(WEEK + DAY), "last week");
232        assert_eq!(format_relative(-(WEEK + DAY)), "next week");
233    }
234
235    #[test]
236    fn weeks() {
237        assert_eq!(format_relative(3 * WEEK), "3 weeks ago");
238    }
239
240    #[test]
241    fn months_and_years() {
242        assert_eq!(format_relative(2 * MONTH), "2 months ago");
243        assert_eq!(format_relative(3 * YEAR), "3 years ago");
244        assert_eq!(format_relative(-(2 * YEAR + DAY)), "in 2 years");
245    }
246
247    #[test]
248    fn last_month_and_next_month() {
249        assert_eq!(format_relative(MONTH + DAY), "last month");
250        assert_eq!(format_relative(-(MONTH + DAY)), "next month");
251    }
252
253    // ── format_since_last ─────────────────────────────────────────────────────
254
255    #[test]
256    fn since_last_zero_or_negative_is_at_the_same_time() {
257        assert_eq!(format_since_last(0), "at the same time");
258        assert_eq!(format_since_last(-1), "at the same time");
259        assert_eq!(format_since_last(-3600), "at the same time");
260    }
261
262    #[test]
263    fn since_last_sub_minute_is_moments_later() {
264        assert_eq!(format_since_last(1), "moments later");
265        assert_eq!(format_since_last(59), "moments later");
266    }
267
268    #[test]
269    fn since_last_one_minute() {
270        assert_eq!(format_since_last(60), "a minute later");
271        // 89s rounds down to 1 minute; 90s rounds up to 2.
272        assert_eq!(format_since_last(89), "a minute later");
273        assert_eq!(format_since_last(90), "2 minutes later");
274    }
275
276    #[test]
277    fn since_last_minutes() {
278        assert_eq!(format_since_last(2 * MINUTE), "2 minutes later");
279        assert_eq!(format_since_last(3599), "60 minutes later");
280    }
281
282    #[test]
283    fn since_last_one_hour() {
284        assert_eq!(format_since_last(HOUR), "an hour later");
285        assert_eq!(format_since_last(HOUR + MINUTE * 25), "an hour later"); // rounds to 1
286    }
287
288    #[test]
289    fn since_last_hours() {
290        assert_eq!(format_since_last(2 * HOUR), "2 hours later");
291        assert_eq!(format_since_last(5 * HOUR), "5 hours later");
292    }
293
294    #[test]
295    fn since_last_later_that_day() {
296        assert_eq!(format_since_last(6 * HOUR), "later that day");
297        assert_eq!(format_since_last(12 * HOUR), "later that day");
298        assert_eq!(format_since_last(23 * HOUR), "later that day");
299    }
300
301    #[test]
302    fn since_last_the_next_day() {
303        assert_eq!(format_since_last(DAY + 1), "the next day");
304        assert_eq!(format_since_last(2 * DAY - 1), "the next day");
305    }
306
307    #[test]
308    fn since_last_days() {
309        assert_eq!(format_since_last(3 * DAY), "3 days later");
310        assert_eq!(format_since_last(6 * DAY), "6 days later");
311    }
312
313    #[test]
314    fn since_last_the_following_week() {
315        assert_eq!(format_since_last(WEEK + 1), "the following week");
316        assert_eq!(format_since_last(13 * DAY), "the following week");
317    }
318
319    #[test]
320    fn since_last_weeks() {
321        assert_eq!(format_since_last(3 * WEEK), "3 weeks later");
322    }
323
324    #[test]
325    fn since_last_the_following_month() {
326        assert_eq!(format_since_last(MONTH + 1), "the following month");
327    }
328
329    #[test]
330    fn since_last_months() {
331        assert_eq!(format_since_last(3 * MONTH), "3 months later");
332    }
333
334    #[test]
335    fn since_last_the_following_year() {
336        assert_eq!(format_since_last(YEAR + 1), "the following year");
337    }
338
339    #[test]
340    fn since_last_years() {
341        assert_eq!(format_since_last(3 * YEAR), "3 years later");
342    }
343}