Skip to main content

max / goingson

7.1 KB · 228 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, NaiveDate, TimeZone, Utc};
10 use chrono_tz::Tz;
11
12 /// Converts a calendar date's local midnight to the corresponding UTC instant.
13 ///
14 /// Day-bucketing (weekly/monthly review timelines, heat-maps, streaks) must use
15 /// the user's *local* day boundaries; stamping a civil midnight as UTC
16 /// misattributes edge-of-day rows by the zone offset. On a DST spring-forward
17 /// gap the civil midnight doesn't exist, so fall back to treating it as UTC
18 /// rather than panicking (mirrors `tz::local_civil_to_utc` in the app crate).
19 pub fn civil_midnight_utc(date: NaiveDate, tz: Tz) -> DateTime<Utc> {
20 let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
21 tz.from_local_datetime(&naive)
22 .earliest()
23 .map(|dt| dt.with_timezone(&Utc))
24 .unwrap_or_else(|| DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
25 }
26
27 /// Formats a date relative to now, bidirectional: "2d ago", "today", "tomorrow", "+3d", "Mar 15".
28 ///
29 /// Used for task due dates and similar bidirectional relative displays.
30 pub fn format_relative_date(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
31 let diff_days = (dt.date_naive() - now.date_naive()).num_days();
32 if diff_days < -1 {
33 format!("{}d ago", -diff_days)
34 } else if diff_days == -1 {
35 "1d ago".to_string()
36 } else 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 future date relative to now: "today", "tomorrow", "+3d", "Mar 15".
48 ///
49 /// Used for snooze-until displays where the date is always in the future.
50 pub fn format_relative_future(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
51 let diff_days = (dt.date_naive() - now.date_naive()).num_days();
52 if diff_days == 0 {
53 "today".to_string()
54 } else if diff_days == 1 {
55 "tomorrow".to_string()
56 } else if diff_days < 7 {
57 format!("+{}d", diff_days)
58 } else {
59 dt.format("%b %d").to_string()
60 }
61 }
62
63 /// Formats a past timestamp as elapsed time: "Just now", "2h ago", "3d ago", "Mar 15".
64 ///
65 /// Used for email received_at displays.
66 pub fn format_elapsed_time(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
67 let diff = now.signed_duration_since(dt);
68 let hours = diff.num_hours();
69 if hours < 1 {
70 "Just now".to_string()
71 } else if hours < 24 {
72 format!("{}h ago", hours)
73 } else {
74 let days = diff.num_days();
75 if days < 7 {
76 format!("{}d ago", days)
77 } else {
78 dt.with_timezone(&Local).format("%b %d").to_string()
79 }
80 }
81 }
82
83 #[cfg(test)]
84 mod tests {
85 use super::*;
86 use chrono::{Duration, NaiveDate, NaiveTime, TimeZone};
87
88 fn utc(year: i32, month: u32, day: u32, hour: u32) -> DateTime<Utc> {
89 let date = NaiveDate::from_ymd_opt(year, month, day).unwrap();
90 let time = NaiveTime::from_hms_opt(hour, 0, 0).unwrap();
91 Utc.from_utc_datetime(&date.and_time(time))
92 }
93
94 // ---- format_relative_date ----
95
96 #[test]
97 fn relative_date_today() {
98 let now = utc(2026, 4, 15, 12);
99 let dt = utc(2026, 4, 15, 8);
100 assert_eq!(format_relative_date(dt, now), "today");
101 }
102
103 #[test]
104 fn relative_date_tomorrow() {
105 let now = utc(2026, 4, 15, 12);
106 let dt = utc(2026, 4, 16, 9);
107 assert_eq!(format_relative_date(dt, now), "tomorrow");
108 }
109
110 #[test]
111 fn relative_date_yesterday() {
112 let now = utc(2026, 4, 15, 12);
113 let dt = utc(2026, 4, 14, 12);
114 assert_eq!(format_relative_date(dt, now), "1d ago");
115 }
116
117 #[test]
118 fn relative_date_multiple_days_ago() {
119 let now = utc(2026, 4, 15, 12);
120 let dt = utc(2026, 4, 12, 12);
121 assert_eq!(format_relative_date(dt, now), "3d ago");
122 }
123
124 #[test]
125 fn relative_date_few_days_future() {
126 let now = utc(2026, 4, 15, 12);
127 let dt = utc(2026, 4, 18, 12);
128 assert_eq!(format_relative_date(dt, now), "+3d");
129 }
130
131 #[test]
132 fn relative_date_far_future() {
133 let now = utc(2026, 4, 15, 12);
134 let dt = utc(2026, 5, 10, 12);
135 assert_eq!(format_relative_date(dt, now), "May 10");
136 }
137
138 #[test]
139 fn relative_date_boundary_six_days() {
140 let now = utc(2026, 4, 15, 12);
141 let dt = utc(2026, 4, 21, 12);
142 assert_eq!(format_relative_date(dt, now), "+6d");
143 }
144
145 #[test]
146 fn relative_date_boundary_seven_days() {
147 let now = utc(2026, 4, 15, 12);
148 let dt = utc(2026, 4, 22, 12);
149 assert_eq!(format_relative_date(dt, now), "Apr 22");
150 }
151
152 // ---- format_relative_future ----
153
154 #[test]
155 fn relative_future_today() {
156 let now = utc(2026, 4, 15, 12);
157 let dt = utc(2026, 4, 15, 17);
158 assert_eq!(format_relative_future(dt, now), "today");
159 }
160
161 #[test]
162 fn relative_future_tomorrow() {
163 let now = utc(2026, 4, 15, 12);
164 let dt = utc(2026, 4, 16, 9);
165 assert_eq!(format_relative_future(dt, now), "tomorrow");
166 }
167
168 #[test]
169 fn relative_future_few_days() {
170 let now = utc(2026, 4, 15, 12);
171 let dt = utc(2026, 4, 19, 9);
172 assert_eq!(format_relative_future(dt, now), "+4d");
173 }
174
175 #[test]
176 fn relative_future_far() {
177 let now = utc(2026, 4, 15, 12);
178 let dt = utc(2026, 6, 1, 9);
179 assert_eq!(format_relative_future(dt, now), "Jun 01");
180 }
181
182 // ---- format_elapsed_time ----
183
184 #[test]
185 fn elapsed_just_now() {
186 let now = utc(2026, 4, 15, 12);
187 let dt = now - Duration::minutes(30);
188 assert_eq!(format_elapsed_time(dt, now), "Just now");
189 }
190
191 #[test]
192 fn elapsed_hours() {
193 let now = utc(2026, 4, 15, 12);
194 let dt = now - Duration::hours(5);
195 assert_eq!(format_elapsed_time(dt, now), "5h ago");
196 }
197
198 #[test]
199 fn elapsed_days() {
200 let now = utc(2026, 4, 15, 12);
201 let dt = now - Duration::days(3);
202 assert_eq!(format_elapsed_time(dt, now), "3d ago");
203 }
204
205 #[test]
206 fn elapsed_weeks_shows_date() {
207 let now = utc(2026, 4, 15, 12);
208 let dt = now - Duration::days(14);
209 // Exact format depends on local timezone, but should contain month abbreviation
210 let result = format_elapsed_time(dt, now);
211 assert!(result.contains("Apr") || result.contains("Mar"), "Expected month abbrev, got: {result}");
212 }
213
214 #[test]
215 fn elapsed_boundary_23h() {
216 let now = utc(2026, 4, 15, 12);
217 let dt = now - Duration::hours(23);
218 assert_eq!(format_elapsed_time(dt, now), "23h ago");
219 }
220
221 #[test]
222 fn elapsed_boundary_24h() {
223 let now = utc(2026, 4, 15, 12);
224 let dt = now - Duration::hours(24);
225 assert_eq!(format_elapsed_time(dt, now), "1d ago");
226 }
227 }
228