Skip to main content

jmap_chat_client/
utils.rs

1//! Display-formatting helpers for JMAP Chat clients.
2
3use chrono::{DateTime, Datelike, Timelike, Utc};
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    let now_str = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
26    let now = UTCDate::from(now_str.as_str());
27    format_receipt_timestamp_at(dt, &now)
28}
29
30/// Like [`format_receipt_timestamp`] but accepts an explicit reference time,
31/// allowing deterministic unit tests and caller-managed timezone offset.
32///
33/// Both `dt` and `now` are treated as UTC. To display in local time, adjust
34/// `now` to the desired reference point before calling.
35///
36/// `now` is typed as [`UTCDate`] (a JMAP-native String newtype, RFC 3339)
37/// rather than `chrono::DateTime<chrono::Utc>` so the public signature
38/// of this crate does not leak the `chrono` major-version into its
39/// SemVer surface. The implementation parses `now` internally and
40/// silently falls back to "Today" if the reference time is itself
41/// unparsable (matching the existing behaviour for `dt`).
42pub fn format_receipt_timestamp_at(dt: &UTCDate, now: &UTCDate) -> String {
43    let parsed = match chrono::DateTime::parse_from_rfc3339(dt.as_ref()) {
44        Ok(d) => d.with_timezone(&Utc),
45        Err(_) => return dt.as_ref().to_string(),
46    };
47
48    // Parse the reference time. If it itself fails to parse (callers
49    // typically supply a fresh Utc::now() via the public free function
50    // above, so this is rare), fall back to treating `dt` as "Today".
51    let now: DateTime<Utc> = match chrono::DateTime::parse_from_rfc3339(now.as_ref()) {
52        Ok(d) => d.with_timezone(&Utc),
53        Err(_) => return "Today".to_string(),
54    };
55
56    let dt_date = parsed.date_naive();
57    let now_date = now.date_naive();
58    let days_diff = (now_date - dt_date).num_days();
59
60    // Negative days_diff means dt is in the future (clock skew); treat as today.
61    match days_diff {
62        ..=0 => "Today".to_string(),
63        1 => "Yesterday".to_string(),
64        2..=6 => {
65            // chrono's %a is locale-independent: short English weekday
66            // name (Mon, Tue, Wed, Thu, Fri, Sat, Sun) regardless of
67            // the host OS locale — matching the prior hand-rolled
68            // table byte-for-byte while delegating to chrono's tested
69            // implementation.
70            format!(
71                "{} {:02}:{:02}",
72                parsed.format("%a"),
73                parsed.hour(),
74                parsed.minute()
75            )
76        }
77        _ => {
78            // %b is the locale-independent short month name (Jan, Feb,
79            // ..., Dec). Same byte-equivalent guarantee as %a above.
80            if parsed.year() != now.year() {
81                format!("{} {} {}", parsed.format("%b"), parsed.day(), parsed.year())
82            } else {
83                format!("{} {}", parsed.format("%b"), parsed.day())
84            }
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    /// Fixed reference time: 2024-03-20T15:00:00Z (Wednesday). Returned
94    /// as `UTCDate` rather than `chrono::DateTime<Utc>` so the test
95    /// suite does not depend on the `chrono` constructor API.
96    fn now() -> UTCDate {
97        UTCDate::from("2024-03-20T15:00:00Z")
98    }
99
100    /// Oracle: timestamp 2h before now (2024-03-20T13:00:00Z) — same calendar day → "Today".
101    #[test]
102    fn format_today() {
103        let dt = UTCDate::from("2024-03-20T13:00:00Z");
104        assert_eq!(format_receipt_timestamp_at(&dt, &now()), "Today");
105    }
106
107    /// Oracle: timestamp 25h before now (2024-03-19T14:00:00Z) — previous calendar day → "Yesterday".
108    #[test]
109    fn format_yesterday() {
110        let dt = UTCDate::from("2024-03-19T14:00:00Z");
111        assert_eq!(format_receipt_timestamp_at(&dt, &now()), "Yesterday");
112    }
113
114    /// Oracle: timestamp 3 days ago (2024-03-17T08:30:00Z — Sunday) → "Sun 08:30".
115    #[test]
116    fn format_this_week() {
117        let dt = UTCDate::from("2024-03-17T08:30:00Z");
118        assert_eq!(format_receipt_timestamp_at(&dt, &now()), "Sun 08:30");
119    }
120
121    /// Oracle: timestamp 8 days ago (2024-03-12T09:00:00Z) — same year → "Mar 12".
122    ///
123    /// Note: the bead spec says "DD/MM/YY" but the reference implementation uses
124    /// "Mon DD" (month abbreviation + day), which is more readable and consistent
125    /// with the rest of the formatting logic. The reference is authoritative.
126    #[test]
127    fn format_old() {
128        let dt = UTCDate::from("2024-03-12T09:00:00Z");
129        assert_eq!(format_receipt_timestamp_at(&dt, &now()), "Mar 12");
130    }
131
132    /// Oracle: unparsable string → returned unchanged (never panics).
133    #[test]
134    fn format_parse_error() {
135        let dt = UTCDate::from("not-a-date");
136        assert_eq!(format_receipt_timestamp_at(&dt, &now()), "not-a-date");
137    }
138
139    /// Oracle: prior year (2023-01-15) includes the year → "Jan 15 2023".
140    #[test]
141    fn format_prior_year() {
142        let dt = UTCDate::from("2023-01-15T09:00:00Z");
143        assert_eq!(format_receipt_timestamp_at(&dt, &now()), "Jan 15 2023");
144    }
145
146    /// Oracle: future timestamp (clock skew, dt > now) formats as "Today".
147    #[test]
148    fn format_future_clock_skew() {
149        let dt = UTCDate::from("2024-03-21T10:00:00Z");
150        assert_eq!(
151            format_receipt_timestamp_at(&dt, &now()),
152            "Today",
153            "future timestamp must display as Today, not as a past date"
154        );
155    }
156
157    /// Oracle: unparsable `now` reference time → fallback to "Today".
158    /// This codifies the silent-degradation behaviour documented on
159    /// the public function and locks it against accidental regression
160    /// to a panic-on-malformed-now path.
161    #[test]
162    fn format_parse_error_on_now() {
163        let dt = UTCDate::from("2024-03-20T13:00:00Z");
164        let bad_now = UTCDate::from("not-a-date");
165        assert_eq!(format_receipt_timestamp_at(&dt, &bad_now), "Today");
166    }
167}