Skip to main content

jmap_chat_client/
utils.rs

1//! Display-formatting helpers for JMAP Chat clients.
2
3use chrono::{DateTime, Datelike, Timelike, Utc, Weekday};
4
5use jmap_types::UTCDate;
6
7/// Format a receipt timestamp without exposing sub-minute precision.
8///
9/// Returns a human-readable relative string:
10/// - Same calendar day as now (or future): `"Today"`
11/// - Previous calendar day: `"Yesterday"`
12/// - 2–6 days ago: `"Mon 14:00"` (weekday abbreviation + HH:MM)
13/// - Same year, older than 6 days: `"Apr 12"` (month abbreviation + day number)
14/// - Different year: `"Apr 12 2023"` (month + day + year)
15/// - Unparsable input: raw string returned unchanged
16///
17/// **UTC dates only.** Both `dt` and the implicit current time are treated as
18/// UTC wall-clock dates. Callers in non-UTC time zones that need local-day
19/// semantics should use [`format_receipt_timestamp_at`] with a local-adjusted
20/// reference time.
21///
22/// **Stability note**: The exact output strings (`"Today"`, `"Jan 15"`, etc.)
23/// are informational display strings, not a stable API contract.
24pub fn format_receipt_timestamp(dt: &UTCDate) -> String {
25    format_receipt_timestamp_at(dt, Utc::now())
26}
27
28/// Like [`format_receipt_timestamp`] but accepts an explicit reference time,
29/// allowing deterministic unit tests and caller-managed timezone offset.
30///
31/// Both `dt` and `now` are treated as UTC. To display in local time, adjust
32/// `now` to the desired reference point before calling.
33pub fn format_receipt_timestamp_at(dt: &UTCDate, now: DateTime<Utc>) -> String {
34    let parsed = match chrono::DateTime::parse_from_rfc3339(dt.as_ref()) {
35        Ok(d) => d.with_timezone(&Utc),
36        Err(_) => return dt.as_ref().to_string(),
37    };
38
39    let dt_date = parsed.date_naive();
40    let now_date = now.date_naive();
41    let days_diff = (now_date - dt_date).num_days();
42
43    // Negative days_diff means dt is in the future (clock skew); treat as today.
44    match days_diff {
45        ..=0 => "Today".to_string(),
46        1 => "Yesterday".to_string(),
47        2..=6 => {
48            let weekday = match parsed.weekday() {
49                Weekday::Mon => "Mon",
50                Weekday::Tue => "Tue",
51                Weekday::Wed => "Wed",
52                Weekday::Thu => "Thu",
53                Weekday::Fri => "Fri",
54                Weekday::Sat => "Sat",
55                Weekday::Sun => "Sun",
56            };
57            format!("{} {:02}:{:02}", weekday, parsed.hour(), parsed.minute())
58        }
59        _ => {
60            let month = match parsed.month() {
61                1 => "Jan",
62                2 => "Feb",
63                3 => "Mar",
64                4 => "Apr",
65                5 => "May",
66                6 => "Jun",
67                7 => "Jul",
68                8 => "Aug",
69                9 => "Sep",
70                10 => "Oct",
71                11 => "Nov",
72                _ => "Dec",
73            };
74            if parsed.year() != now.year() {
75                format!("{} {} {}", month, parsed.day(), parsed.year())
76            } else {
77                format!("{} {}", month, parsed.day())
78            }
79        }
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use chrono::TimeZone;
87
88    /// Fixed reference time: 2024-03-20T15:00:00Z (Wednesday)
89    fn now() -> DateTime<Utc> {
90        Utc.with_ymd_and_hms(2024, 3, 20, 15, 0, 0).unwrap()
91    }
92
93    /// Oracle: timestamp 2h before now (2024-03-20T13:00:00Z) — same calendar day → "Today".
94    #[test]
95    fn format_today() {
96        let dt = UTCDate::from("2024-03-20T13:00:00Z");
97        assert_eq!(format_receipt_timestamp_at(&dt, now()), "Today");
98    }
99
100    /// Oracle: timestamp 25h before now (2024-03-19T14:00:00Z) — previous calendar day → "Yesterday".
101    #[test]
102    fn format_yesterday() {
103        let dt = UTCDate::from("2024-03-19T14:00:00Z");
104        assert_eq!(format_receipt_timestamp_at(&dt, now()), "Yesterday");
105    }
106
107    /// Oracle: timestamp 3 days ago (2024-03-17T08:30:00Z — Sunday) → "Sun 08:30".
108    #[test]
109    fn format_this_week() {
110        let dt = UTCDate::from("2024-03-17T08:30:00Z");
111        assert_eq!(format_receipt_timestamp_at(&dt, now()), "Sun 08:30");
112    }
113
114    /// Oracle: timestamp 8 days ago (2024-03-12T09:00:00Z) — same year → "Mar 12".
115    ///
116    /// Note: the bead spec says "DD/MM/YY" but the reference implementation uses
117    /// "Mon DD" (month abbreviation + day), which is more readable and consistent
118    /// with the rest of the formatting logic. The reference is authoritative.
119    #[test]
120    fn format_old() {
121        let dt = UTCDate::from("2024-03-12T09:00:00Z");
122        assert_eq!(format_receipt_timestamp_at(&dt, now()), "Mar 12");
123    }
124
125    /// Oracle: unparsable string → returned unchanged (never panics).
126    #[test]
127    fn format_parse_error() {
128        let dt = UTCDate::from("not-a-date");
129        assert_eq!(format_receipt_timestamp_at(&dt, now()), "not-a-date");
130    }
131
132    /// Oracle: prior year (2023-01-15) includes the year → "Jan 15 2023".
133    #[test]
134    fn format_prior_year() {
135        let dt = UTCDate::from("2023-01-15T09:00:00Z");
136        assert_eq!(format_receipt_timestamp_at(&dt, now()), "Jan 15 2023");
137    }
138
139    /// Oracle: future timestamp (clock skew, dt > now) formats as "Today".
140    #[test]
141    fn format_future_clock_skew() {
142        let dt = UTCDate::from("2024-03-21T10:00:00Z");
143        assert_eq!(
144            format_receipt_timestamp_at(&dt, now()),
145            "Today",
146            "future timestamp must display as Today, not as a past date"
147        );
148    }
149}