Skip to main content

max / goingson

6.2 KB · 212 lines History Blame Raw
1 //! Human-readable date/time formatting utilities.
2 //!
3 //! Provides functions that turn absolute timestamps into relative display
4 //! strings for the UI (e.g. "today", "2d ago", "+3d", "Just now").
5 //!
6 //! All functions accept an explicit `now` parameter so they are
7 //! deterministic and easy to test.
8
9 use chrono::{DateTime, Local, Utc};
10
11 /// Formats a date relative to now, bidirectional: "2d ago", "today", "tomorrow", "+3d", "Mar 15".
12 ///
13 /// Used for task due dates and similar bidirectional relative displays.
14 pub fn format_relative_date(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
15 let diff_days = (dt.date_naive() - now.date_naive()).num_days();
16 if diff_days < -1 {
17 format!("{}d ago", -diff_days)
18 } else if diff_days == -1 {
19 "1d ago".to_string()
20 } else if diff_days == 0 {
21 "today".to_string()
22 } else if diff_days == 1 {
23 "tomorrow".to_string()
24 } else if diff_days < 7 {
25 format!("+{}d", diff_days)
26 } else {
27 dt.format("%b %d").to_string()
28 }
29 }
30
31 /// Formats a future date relative to now: "today", "tomorrow", "+3d", "Mar 15".
32 ///
33 /// Used for snooze-until displays where the date is always in the future.
34 pub fn format_relative_future(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
35 let diff_days = (dt.date_naive() - now.date_naive()).num_days();
36 if diff_days == 0 {
37 "today".to_string()
38 } else if diff_days == 1 {
39 "tomorrow".to_string()
40 } else if diff_days < 7 {
41 format!("+{}d", diff_days)
42 } else {
43 dt.format("%b %d").to_string()
44 }
45 }
46
47 /// Formats a past timestamp as elapsed time: "Just now", "2h ago", "3d ago", "Mar 15".
48 ///
49 /// Used for email received_at displays.
50 pub fn format_elapsed_time(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
51 let diff = now.signed_duration_since(dt);
52 let hours = diff.num_hours();
53 if hours < 1 {
54 "Just now".to_string()
55 } else if hours < 24 {
56 format!("{}h ago", hours)
57 } else {
58 let days = diff.num_days();
59 if days < 7 {
60 format!("{}d ago", days)
61 } else {
62 dt.with_timezone(&Local).format("%b %d").to_string()
63 }
64 }
65 }
66
67 #[cfg(test)]
68 mod tests {
69 use super::*;
70 use chrono::{Duration, NaiveDate, NaiveTime, TimeZone};
71
72 fn utc(year: i32, month: u32, day: u32, hour: u32) -> DateTime<Utc> {
73 let date = NaiveDate::from_ymd_opt(year, month, day).unwrap();
74 let time = NaiveTime::from_hms_opt(hour, 0, 0).unwrap();
75 Utc.from_utc_datetime(&date.and_time(time))
76 }
77
78 // ---- format_relative_date ----
79
80 #[test]
81 fn relative_date_today() {
82 let now = utc(2026, 4, 15, 12);
83 let dt = utc(2026, 4, 15, 8);
84 assert_eq!(format_relative_date(dt, now), "today");
85 }
86
87 #[test]
88 fn relative_date_tomorrow() {
89 let now = utc(2026, 4, 15, 12);
90 let dt = utc(2026, 4, 16, 9);
91 assert_eq!(format_relative_date(dt, now), "tomorrow");
92 }
93
94 #[test]
95 fn relative_date_yesterday() {
96 let now = utc(2026, 4, 15, 12);
97 let dt = utc(2026, 4, 14, 12);
98 assert_eq!(format_relative_date(dt, now), "1d ago");
99 }
100
101 #[test]
102 fn relative_date_multiple_days_ago() {
103 let now = utc(2026, 4, 15, 12);
104 let dt = utc(2026, 4, 12, 12);
105 assert_eq!(format_relative_date(dt, now), "3d ago");
106 }
107
108 #[test]
109 fn relative_date_few_days_future() {
110 let now = utc(2026, 4, 15, 12);
111 let dt = utc(2026, 4, 18, 12);
112 assert_eq!(format_relative_date(dt, now), "+3d");
113 }
114
115 #[test]
116 fn relative_date_far_future() {
117 let now = utc(2026, 4, 15, 12);
118 let dt = utc(2026, 5, 10, 12);
119 assert_eq!(format_relative_date(dt, now), "May 10");
120 }
121
122 #[test]
123 fn relative_date_boundary_six_days() {
124 let now = utc(2026, 4, 15, 12);
125 let dt = utc(2026, 4, 21, 12);
126 assert_eq!(format_relative_date(dt, now), "+6d");
127 }
128
129 #[test]
130 fn relative_date_boundary_seven_days() {
131 let now = utc(2026, 4, 15, 12);
132 let dt = utc(2026, 4, 22, 12);
133 assert_eq!(format_relative_date(dt, now), "Apr 22");
134 }
135
136 // ---- format_relative_future ----
137
138 #[test]
139 fn relative_future_today() {
140 let now = utc(2026, 4, 15, 12);
141 let dt = utc(2026, 4, 15, 17);
142 assert_eq!(format_relative_future(dt, now), "today");
143 }
144
145 #[test]
146 fn relative_future_tomorrow() {
147 let now = utc(2026, 4, 15, 12);
148 let dt = utc(2026, 4, 16, 9);
149 assert_eq!(format_relative_future(dt, now), "tomorrow");
150 }
151
152 #[test]
153 fn relative_future_few_days() {
154 let now = utc(2026, 4, 15, 12);
155 let dt = utc(2026, 4, 19, 9);
156 assert_eq!(format_relative_future(dt, now), "+4d");
157 }
158
159 #[test]
160 fn relative_future_far() {
161 let now = utc(2026, 4, 15, 12);
162 let dt = utc(2026, 6, 1, 9);
163 assert_eq!(format_relative_future(dt, now), "Jun 01");
164 }
165
166 // ---- format_elapsed_time ----
167
168 #[test]
169 fn elapsed_just_now() {
170 let now = utc(2026, 4, 15, 12);
171 let dt = now - Duration::minutes(30);
172 assert_eq!(format_elapsed_time(dt, now), "Just now");
173 }
174
175 #[test]
176 fn elapsed_hours() {
177 let now = utc(2026, 4, 15, 12);
178 let dt = now - Duration::hours(5);
179 assert_eq!(format_elapsed_time(dt, now), "5h ago");
180 }
181
182 #[test]
183 fn elapsed_days() {
184 let now = utc(2026, 4, 15, 12);
185 let dt = now - Duration::days(3);
186 assert_eq!(format_elapsed_time(dt, now), "3d ago");
187 }
188
189 #[test]
190 fn elapsed_weeks_shows_date() {
191 let now = utc(2026, 4, 15, 12);
192 let dt = now - Duration::days(14);
193 // Exact format depends on local timezone, but should contain month abbreviation
194 let result = format_elapsed_time(dt, now);
195 assert!(result.contains("Apr") || result.contains("Mar"), "Expected month abbrev, got: {result}");
196 }
197
198 #[test]
199 fn elapsed_boundary_23h() {
200 let now = utc(2026, 4, 15, 12);
201 let dt = now - Duration::hours(23);
202 assert_eq!(format_elapsed_time(dt, now), "23h ago");
203 }
204
205 #[test]
206 fn elapsed_boundary_24h() {
207 let now = utc(2026, 4, 15, 12);
208 let dt = now - Duration::hours(24);
209 assert_eq!(format_elapsed_time(dt, now), "1d ago");
210 }
211 }
212