time_format.rs

  1use time::{OffsetDateTime, UtcOffset};
  2
  3/// The formatting style for a timestamp.
  4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
  5pub enum TimestampFormat {
  6    /// Formats the timestamp as an absolute time, e.g. "2021-12-31 3:00AM".
  7    Absolute,
  8    /// Formats the timestamp as an absolute time.
  9    /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
 10    /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
 11    EnhancedAbsolute,
 12    /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
 13    Relative,
 14}
 15
 16/// Formats a timestamp, which respects the user's date and time preferences/custom format.
 17pub fn format_localized_timestamp(
 18    timestamp: OffsetDateTime,
 19    reference: OffsetDateTime,
 20    timezone: UtcOffset,
 21    format: TimestampFormat,
 22) -> String {
 23    let timestamp_local = timestamp.to_offset(timezone);
 24    let reference_local = reference.to_offset(timezone);
 25
 26    match format {
 27        TimestampFormat::Absolute => {
 28            format_absolute_timestamp(timestamp_local, reference_local, false)
 29        }
 30        TimestampFormat::EnhancedAbsolute => {
 31            format_absolute_timestamp(timestamp_local, reference_local, true)
 32        }
 33        TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
 34            .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
 35    }
 36}
 37
 38fn format_absolute_timestamp(
 39    timestamp: OffsetDateTime,
 40    reference: OffsetDateTime,
 41    #[allow(unused_variables)] enhanced_date_formatting: bool,
 42) -> String {
 43    #[cfg(target_os = "macos")]
 44    {
 45        if !enhanced_date_formatting {
 46            return format!(
 47                "{} {}",
 48                macos::format_date(&timestamp),
 49                macos::format_time(&timestamp)
 50            );
 51        }
 52
 53        let timestamp_date = timestamp.date();
 54        let reference_date = reference.date();
 55        if timestamp_date == reference_date {
 56            format!("Today at {}", macos::format_time(&timestamp))
 57        } else if reference_date.previous_day() == Some(timestamp_date) {
 58            format!("Yesterday at {}", macos::format_time(&timestamp))
 59        } else {
 60            format!(
 61                "{} {}",
 62                macos::format_date(&timestamp),
 63                macos::format_time(&timestamp)
 64            )
 65        }
 66    }
 67    #[cfg(not(target_os = "macos"))]
 68    {
 69        // todo(linux) respect user's date/time preferences
 70        // todo(windows) respect user's date/time preferences
 71        format_timestamp_fallback(timestamp, reference)
 72    }
 73}
 74
 75fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
 76    let difference = reference - timestamp;
 77    let minutes = difference.whole_minutes();
 78    match minutes {
 79        0 => Some("Just now".to_string()),
 80        1 => Some("1 minute ago".to_string()),
 81        2..=59 => Some(format!("{} minutes ago", minutes)),
 82        _ => {
 83            let hours = difference.whole_hours();
 84            match hours {
 85                1 => Some("1 hour ago".to_string()),
 86                2..=23 => Some(format!("{} hours ago", hours)),
 87                _ => None,
 88            }
 89        }
 90    }
 91}
 92
 93fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
 94    let timestamp_date = timestamp.date();
 95    let reference_date = reference.date();
 96    let difference = reference_date - timestamp_date;
 97    let days = difference.whole_days();
 98    match days {
 99        0 => "Today".to_string(),
100        1 => "Yesterday".to_string(),
101        2..=6 => format!("{} days ago", days),
102        _ => {
103            let weeks = difference.whole_weeks();
104            match weeks {
105                1 => "1 week ago".to_string(),
106                2..=4 => format!("{} weeks ago", weeks),
107                _ => {
108                    let month_diff = calculate_month_difference(timestamp, reference);
109                    match month_diff {
110                        0..=1 => "1 month ago".to_string(),
111                        2..=11 => format!("{} months ago", month_diff),
112                        _ => {
113                            let timestamp_year = timestamp_date.year();
114                            let reference_year = reference_date.year();
115                            let years = reference_year - timestamp_year;
116                            match years {
117                                1 => "1 year ago".to_string(),
118                                _ => format!("{} years ago", years),
119                            }
120                        }
121                    }
122                }
123            }
124        }
125    }
126}
127
128/// Calculates the difference in months between two timestamps.
129/// The reference timestamp should always be greater than the timestamp.
130fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize {
131    let timestamp_year = timestamp.year();
132    let reference_year = reference.year();
133    let timestamp_month: u8 = timestamp.month().into();
134    let reference_month: u8 = reference.month().into();
135
136    let month_diff = if reference_month >= timestamp_month {
137        reference_month as usize - timestamp_month as usize
138    } else {
139        12 - timestamp_month as usize + reference_month as usize
140    };
141
142    let year_diff = (reference_year - timestamp_year) as usize;
143    if year_diff == 0 {
144        reference_month as usize - timestamp_month as usize
145    } else {
146        if month_diff == 0 {
147            year_diff * 12
148        } else if timestamp_month > reference_month {
149            (year_diff - 1) * 12 + month_diff
150        } else {
151            year_diff * 12 + month_diff
152        }
153    }
154}
155
156/// Formats a timestamp, which is either in 12-hour or 24-hour time format.
157/// Note:
158/// This function does not respect the user's date and time preferences.
159/// This should only be used as a fallback mechanism when the OS time formatting fails.
160pub fn format_timestamp_naive(
161    timestamp_local: OffsetDateTime,
162    reference_local: OffsetDateTime,
163    is_12_hour_time: bool,
164) -> String {
165    let timestamp_local_hour = timestamp_local.hour();
166    let timestamp_local_minute = timestamp_local.minute();
167    let reference_local_date = reference_local.date();
168    let timestamp_local_date = timestamp_local.date();
169
170    let (hour, meridiem) = if is_12_hour_time {
171        let meridiem = if timestamp_local_hour >= 12 {
172            "PM"
173        } else {
174            "AM"
175        };
176
177        let hour_12 = match timestamp_local_hour {
178            0 => 12,                              // Midnight
179            13..=23 => timestamp_local_hour - 12, // PM hours
180            _ => timestamp_local_hour,            // AM hours
181        };
182
183        (hour_12, Some(meridiem))
184    } else {
185        (timestamp_local_hour, None)
186    };
187
188    let formatted_time = match meridiem {
189        Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem),
190        None => format!("{:02}:{:02}", hour, timestamp_local_minute),
191    };
192
193    let formatted_date = match meridiem {
194        Some(_) => format!(
195            "{:02}/{:02}/{}",
196            timestamp_local_date.month() as u32,
197            timestamp_local_date.day(),
198            timestamp_local_date.year()
199        ),
200        None => format!(
201            "{:02}/{:02}/{}",
202            timestamp_local_date.day(),
203            timestamp_local_date.month() as u32,
204            timestamp_local_date.year()
205        ),
206    };
207
208    if timestamp_local_date == reference_local_date {
209        format!("Today at {}", formatted_time)
210    } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
211        format!("Yesterday at {}", formatted_time)
212    } else {
213        format!("{} {}", formatted_date, formatted_time)
214    }
215}
216
217#[cfg(not(target_os = "macos"))]
218fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
219    static CURRENT_LOCALE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
220    let current_locale = CURRENT_LOCALE
221        .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
222
223    let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str());
224    format_timestamp_naive(timestamp, reference, is_12_hour_time)
225}
226
227#[cfg(not(target_os = "macos"))]
228/// Returns `true` if the locale is recognized as a 12-hour time locale.
229fn is_12_hour_time_by_locale(locale: &str) -> bool {
230    [
231        "es-MX", "es-CO", "es-SV", "es-NI",
232        "es-HN", // Mexico, Colombia, El Salvador, Nicaragua, Honduras
233        "en-US", "en-CA", "en-AU", "en-NZ", // U.S, Canada, Australia, New Zealand
234        "ar-SA", "ar-EG", "ar-JO", // Saudi Arabia, Egypt, Jordan
235        "en-IN", "hi-IN", // India, Hindu
236        "en-PK", "ur-PK", // Pakistan, Urdu
237        "en-PH", "fil-PH", // Philippines, Filipino
238        "bn-BD", "ccp-BD", // Bangladesh, Chakma
239        "en-IE", "ga-IE", // Ireland, Irish
240        "en-MY", "ms-MY", // Malaysia, Malay
241    ]
242    .contains(&locale)
243}
244
245#[cfg(target_os = "macos")]
246mod macos {
247    use core_foundation::base::TCFType;
248    use core_foundation::date::CFAbsoluteTime;
249    use core_foundation::string::CFString;
250    use core_foundation_sys::date_formatter::CFDateFormatterCreateStringWithAbsoluteTime;
251    use core_foundation_sys::date_formatter::CFDateFormatterRef;
252    use core_foundation_sys::locale::CFLocaleRef;
253    use core_foundation_sys::{
254        base::kCFAllocatorDefault,
255        date_formatter::{
256            kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate,
257        },
258        locale::CFLocaleCopyCurrent,
259    };
260
261    pub fn format_time(timestamp: &time::OffsetDateTime) -> String {
262        format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f))
263    }
264
265    pub fn format_date(timestamp: &time::OffsetDateTime) -> String {
266        format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
267    }
268
269    fn format_with_date_formatter(
270        timestamp: &time::OffsetDateTime,
271        fmt: CFDateFormatterRef,
272    ) -> String {
273        const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200;
274        // Convert timestamp to macOS absolute time
275        let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET;
276        let cf_absolute_time = timestamp_macos as CFAbsoluteTime;
277        unsafe {
278            let s = CFDateFormatterCreateStringWithAbsoluteTime(
279                kCFAllocatorDefault,
280                fmt,
281                cf_absolute_time,
282            );
283            CFString::wrap_under_create_rule(s).to_string()
284        }
285    }
286
287    thread_local! {
288        static CURRENT_LOCALE: CFLocaleRef = unsafe { CFLocaleCopyCurrent() };
289        static TIME_FORMATTER: CFDateFormatterRef = unsafe {
290            CFDateFormatterCreate(
291                kCFAllocatorDefault,
292                CURRENT_LOCALE.with(|locale| *locale),
293                kCFDateFormatterNoStyle,
294                kCFDateFormatterShortStyle,
295            )
296        };
297        static DATE_FORMATTER: CFDateFormatterRef = unsafe {
298            CFDateFormatterCreate(
299                kCFAllocatorDefault,
300                CURRENT_LOCALE.with(|locale| *locale),
301                kCFDateFormatterShortStyle,
302                kCFDateFormatterNoStyle,
303            )
304        };
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_format_24_hour_time() {
314        let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
315        let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
316
317        assert_eq!(
318            format_timestamp_naive(timestamp, reference, false),
319            "Today at 15:30"
320        );
321    }
322
323    #[test]
324    fn test_format_today() {
325        let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
326        let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
327
328        assert_eq!(
329            format_timestamp_naive(timestamp, reference, true),
330            "Today at 3:30 PM"
331        );
332    }
333
334    #[test]
335    fn test_format_yesterday() {
336        let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
337        let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0);
338
339        assert_eq!(
340            format_timestamp_naive(timestamp, reference, true),
341            "Yesterday at 9:00 AM"
342        );
343    }
344
345    #[test]
346    fn test_format_yesterday_less_than_24_hours_ago() {
347        let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
348        let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0);
349
350        assert_eq!(
351            format_timestamp_naive(timestamp, reference, true),
352            "Yesterday at 8:00 PM"
353        );
354    }
355
356    #[test]
357    fn test_format_yesterday_more_than_24_hours_ago() {
358        let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
359        let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0);
360
361        assert_eq!(
362            format_timestamp_naive(timestamp, reference, true),
363            "Yesterday at 6:00 PM"
364        );
365    }
366
367    #[test]
368    fn test_format_yesterday_over_midnight() {
369        let reference = create_offset_datetime(1990, 4, 12, 0, 5, 0);
370        let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0);
371
372        assert_eq!(
373            format_timestamp_naive(timestamp, reference, true),
374            "Yesterday at 11:55 PM"
375        );
376    }
377
378    #[test]
379    fn test_format_yesterday_over_month() {
380        let reference = create_offset_datetime(1990, 4, 2, 9, 0, 0);
381        let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0);
382
383        assert_eq!(
384            format_timestamp_naive(timestamp, reference, true),
385            "Yesterday at 8:00 PM"
386        );
387    }
388
389    #[test]
390    fn test_format_before_yesterday() {
391        let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
392        let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0);
393
394        assert_eq!(
395            format_timestamp_naive(timestamp, reference, true),
396            "04/10/1990 8:20 PM"
397        );
398    }
399
400    #[test]
401    fn test_relative_format_minutes() {
402        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
403        let mut current_timestamp = reference;
404
405        let mut next_minute = || {
406            current_timestamp = if current_timestamp.minute() == 0 {
407                current_timestamp
408                    .replace_hour(current_timestamp.hour() - 1)
409                    .unwrap()
410                    .replace_minute(59)
411                    .unwrap()
412            } else {
413                current_timestamp
414                    .replace_minute(current_timestamp.minute() - 1)
415                    .unwrap()
416            };
417            current_timestamp
418        };
419
420        assert_eq!(
421            format_relative_time(reference, reference),
422            Some("Just now".to_string())
423        );
424
425        assert_eq!(
426            format_relative_time(next_minute(), reference),
427            Some("1 minute ago".to_string())
428        );
429
430        for i in 2..=59 {
431            assert_eq!(
432                format_relative_time(next_minute(), reference),
433                Some(format!("{} minutes ago", i))
434            );
435        }
436
437        assert_eq!(
438            format_relative_time(next_minute(), reference),
439            Some("1 hour ago".to_string())
440        );
441    }
442
443    #[test]
444    fn test_relative_format_hours() {
445        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
446        let mut current_timestamp = reference;
447
448        let mut next_hour = || {
449            current_timestamp = if current_timestamp.hour() == 0 {
450                let date = current_timestamp.date().previous_day().unwrap();
451                current_timestamp.replace_date(date)
452            } else {
453                current_timestamp
454                    .replace_hour(current_timestamp.hour() - 1)
455                    .unwrap()
456            };
457            current_timestamp
458        };
459
460        assert_eq!(
461            format_relative_time(next_hour(), reference),
462            Some("1 hour ago".to_string())
463        );
464
465        for i in 2..=23 {
466            assert_eq!(
467                format_relative_time(next_hour(), reference),
468                Some(format!("{} hours ago", i))
469            );
470        }
471
472        assert_eq!(format_relative_time(next_hour(), reference), None);
473    }
474
475    #[test]
476    fn test_relative_format_days() {
477        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
478        let mut current_timestamp = reference;
479
480        let mut next_day = || {
481            let date = current_timestamp.date().previous_day().unwrap();
482            current_timestamp = current_timestamp.replace_date(date);
483            current_timestamp
484        };
485
486        assert_eq!(
487            format_relative_date(reference, reference),
488            "Today".to_string()
489        );
490
491        assert_eq!(
492            format_relative_date(next_day(), reference),
493            "Yesterday".to_string()
494        );
495
496        for i in 2..=6 {
497            assert_eq!(
498                format_relative_date(next_day(), reference),
499                format!("{} days ago", i)
500            );
501        }
502
503        assert_eq!(format_relative_date(next_day(), reference), "1 week ago");
504    }
505
506    #[test]
507    fn test_relative_format_weeks() {
508        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
509        let mut current_timestamp = reference;
510
511        let mut next_week = || {
512            for _ in 0..7 {
513                let date = current_timestamp.date().previous_day().unwrap();
514                current_timestamp = current_timestamp.replace_date(date);
515            }
516            current_timestamp
517        };
518
519        assert_eq!(
520            format_relative_date(next_week(), reference),
521            "1 week ago".to_string()
522        );
523
524        for i in 2..=4 {
525            assert_eq!(
526                format_relative_date(next_week(), reference),
527                format!("{} weeks ago", i)
528            );
529        }
530
531        assert_eq!(format_relative_date(next_week(), reference), "1 month ago");
532    }
533
534    #[test]
535    fn test_relative_format_months() {
536        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
537        let mut current_timestamp = reference;
538
539        let mut next_month = || {
540            if current_timestamp.month() == time::Month::January {
541                current_timestamp = current_timestamp
542                    .replace_month(time::Month::December)
543                    .unwrap()
544                    .replace_year(current_timestamp.year() - 1)
545                    .unwrap();
546            } else {
547                current_timestamp = current_timestamp
548                    .replace_month(current_timestamp.month().previous())
549                    .unwrap();
550            }
551            current_timestamp
552        };
553
554        assert_eq!(
555            format_relative_date(next_month(), reference),
556            "4 weeks ago".to_string()
557        );
558
559        for i in 2..=11 {
560            assert_eq!(
561                format_relative_date(next_month(), reference),
562                format!("{} months ago", i)
563            );
564        }
565
566        assert_eq!(format_relative_date(next_month(), reference), "1 year ago");
567    }
568
569    #[test]
570    fn test_calculate_month_difference() {
571        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
572
573        assert_eq!(calculate_month_difference(reference, reference), 0);
574
575        assert_eq!(
576            calculate_month_difference(create_offset_datetime(1990, 1, 12, 23, 0, 0), reference),
577            3
578        );
579
580        assert_eq!(
581            calculate_month_difference(create_offset_datetime(1989, 11, 12, 23, 0, 0), reference),
582            5
583        );
584
585        assert_eq!(
586            calculate_month_difference(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference),
587            12
588        );
589
590        assert_eq!(
591            calculate_month_difference(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference),
592            13
593        );
594
595        assert_eq!(
596            calculate_month_difference(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference),
597            35
598        );
599
600        assert_eq!(
601            calculate_month_difference(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference),
602            36
603        );
604
605        assert_eq!(
606            calculate_month_difference(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference),
607            37
608        );
609    }
610
611    fn test_timezone() -> UtcOffset {
612        UtcOffset::from_hms(0, 0, 0).expect("Valid timezone offset")
613    }
614
615    fn create_offset_datetime(
616        year: i32,
617        month: u8,
618        day: u8,
619        hour: u8,
620        minute: u8,
621        second: u8,
622    ) -> OffsetDateTime {
623        let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
624            .unwrap();
625        let time = time::Time::from_hms(hour, minute, second).unwrap();
626        let date = date.with_time(time).assume_utc(); // Assume UTC for simplicity
627        date.to_offset(test_timezone())
628    }
629}