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