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