time_format.rs

  1use anyhow::Result;
  2use lazy_static::lazy_static;
  3use time::{OffsetDateTime, UtcOffset};
  4
  5/// Formats a timestamp, which respects the user's date and time preferences/custom format.
  6pub fn format_localized_timestamp(
  7    reference: OffsetDateTime,
  8    timestamp: OffsetDateTime,
  9    timezone: UtcOffset,
 10) -> String {
 11    #[cfg(target_os = "macos")]
 12    {
 13        let timestamp_local = timestamp.to_offset(timezone);
 14        let reference_local = reference.to_offset(timezone);
 15        let reference_local_date = reference_local.date();
 16        let timestamp_local_date = timestamp_local.date();
 17
 18        let native_fmt = if timestamp_local_date == reference_local_date {
 19            macos::format_time(&timestamp)
 20        } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
 21            macos::format_time(&timestamp).map(|t| format!("yesterday at {}", t).to_string())
 22        } else {
 23            macos::format_date(&timestamp)
 24        };
 25        native_fmt.unwrap_or_else(|_| format_timestamp_fallback(reference, timestamp, timezone))
 26    }
 27    #[cfg(not(target_os = "macos"))]
 28    {
 29        //todo!(linux) respect user's date/time preferences
 30        //todo!(windows) respect user's date/time preferences
 31        format_timestamp_fallback(reference, timestamp, timezone)
 32    }
 33}
 34
 35fn format_timestamp_fallback(
 36    reference: OffsetDateTime,
 37    timestamp: OffsetDateTime,
 38    timezone: UtcOffset,
 39) -> String {
 40    lazy_static! {
 41        static ref CURRENT_LOCALE: String =
 42            sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
 43    }
 44    let is_12_hour_time = is_12_hour_time_by_locale(CURRENT_LOCALE.as_str());
 45    format_timestamp_naive(reference, timestamp, timezone, is_12_hour_time)
 46}
 47
 48/// Formats a timestamp, which is either in 12-hour or 24-hour time format.
 49/// Note:
 50/// This function does not respect the user's date and time preferences.
 51/// This should only be used as a fallback mechanism when the os time formatting fails.
 52pub fn format_timestamp_naive(
 53    reference: OffsetDateTime,
 54    timestamp: OffsetDateTime,
 55    timezone: UtcOffset,
 56    is_12_hour_time: bool,
 57) -> String {
 58    let timestamp_local = timestamp.to_offset(timezone);
 59    let timestamp_local_hour = timestamp_local.hour();
 60    let timestamp_local_minute = timestamp_local.minute();
 61
 62    let (hour, meridiem) = if is_12_hour_time {
 63        let meridiem = if timestamp_local_hour >= 12 {
 64            "pm"
 65        } else {
 66            "am"
 67        };
 68
 69        let hour_12 = match timestamp_local_hour {
 70            0 => 12,                              // Midnight
 71            13..=23 => timestamp_local_hour - 12, // PM hours
 72            _ => timestamp_local_hour,            // AM hours
 73        };
 74
 75        (hour_12, Some(meridiem))
 76    } else {
 77        (timestamp_local_hour, None)
 78    };
 79
 80    let formatted_time = match meridiem {
 81        Some(meridiem) => format!("{:02}:{:02} {}", hour, timestamp_local_minute, meridiem),
 82        None => format!("{:02}:{:02}", hour, timestamp_local_minute),
 83    };
 84
 85    let reference_local = reference.to_offset(timezone);
 86    let reference_local_date = reference_local.date();
 87    let timestamp_local_date = timestamp_local.date();
 88
 89    if timestamp_local_date == reference_local_date {
 90        return formatted_time;
 91    }
 92
 93    if reference_local_date.previous_day() == Some(timestamp_local_date) {
 94        return format!("yesterday at {}", formatted_time);
 95    }
 96
 97    match meridiem {
 98        Some(_) => format!(
 99            "{:02}/{:02}/{}",
100            timestamp_local_date.month() as u32,
101            timestamp_local_date.day(),
102            timestamp_local_date.year()
103        ),
104        None => format!(
105            "{:02}/{:02}/{}",
106            timestamp_local_date.day(),
107            timestamp_local_date.month() as u32,
108            timestamp_local_date.year()
109        ),
110    }
111}
112
113/// Returns true if the locale is recognized as a 12-hour time locale.
114fn is_12_hour_time_by_locale(locale: &str) -> bool {
115    [
116        "es-MX", "es-CO", "es-SV", "es-NI",
117        "es-HN", // Mexico, Colombia, El Salvador, Nicaragua, Honduras
118        "en-US", "en-CA", "en-AU", "en-NZ", // U.S, Canada, Australia, New Zealand
119        "ar-SA", "ar-EG", "ar-JO", // Saudi Arabia, Egypt, Jordan
120        "en-IN", "hi-IN", // India, Hindu
121        "en-PK", "ur-PK", // Pakistan, Urdu
122        "en-PH", "fil-PH", // Philippines, Filipino
123        "bn-BD", "ccp-BD", // Bangladesh, Chakma
124        "en-IE", "ga-IE", // Ireland, Irish
125        "en-MY", "ms-MY", // Malaysia, Malay
126    ]
127    .contains(&locale)
128}
129
130#[cfg(target_os = "macos")]
131mod macos {
132    use super::*;
133    use core_foundation::base::TCFType;
134    use core_foundation::date::CFAbsoluteTime;
135    use core_foundation::string::CFString;
136    use core_foundation_sys::date_formatter::CFDateFormatterCreateStringWithAbsoluteTime;
137    use core_foundation_sys::date_formatter::CFDateFormatterRef;
138    use core_foundation_sys::locale::CFLocaleRef;
139    use core_foundation_sys::{
140        base::kCFAllocatorDefault,
141        date_formatter::{
142            kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate,
143        },
144        locale::CFLocaleCopyCurrent,
145    };
146
147    pub fn format_time(timestamp: &time::OffsetDateTime) -> Result<String> {
148        format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f))
149    }
150
151    pub fn format_date(timestamp: &time::OffsetDateTime) -> Result<String> {
152        format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
153    }
154
155    fn format_with_date_formatter(
156        timestamp: &time::OffsetDateTime,
157        fmt: CFDateFormatterRef,
158    ) -> Result<String> {
159        const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200;
160        // Convert timestamp to macOS absolute time
161        let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET;
162        let cf_absolute_time = timestamp_macos as CFAbsoluteTime;
163        unsafe {
164            let s = CFDateFormatterCreateStringWithAbsoluteTime(
165                kCFAllocatorDefault,
166                fmt,
167                cf_absolute_time,
168            );
169            Ok(CFString::wrap_under_create_rule(s).to_string())
170        }
171    }
172
173    thread_local! {
174        static CURRENT_LOCALE: CFLocaleRef = unsafe { CFLocaleCopyCurrent() };
175        static TIME_FORMATTER: CFDateFormatterRef = unsafe {
176            CFDateFormatterCreate(
177                kCFAllocatorDefault,
178                CURRENT_LOCALE.with(|locale| *locale),
179                kCFDateFormatterNoStyle,
180                kCFDateFormatterShortStyle,
181            )
182        };
183        static DATE_FORMATTER: CFDateFormatterRef = unsafe {
184            CFDateFormatterCreate(
185                kCFAllocatorDefault,
186                CURRENT_LOCALE.with(|locale| *locale),
187                kCFDateFormatterShortStyle,
188                kCFDateFormatterNoStyle,
189            )
190        };
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_format_24_hour_time() {
200        let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
201        let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
202
203        assert_eq!(
204            format_timestamp_naive(reference, timestamp, test_timezone(), false),
205            "15:30"
206        );
207    }
208
209    #[test]
210    fn test_format_today() {
211        let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
212        let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
213
214        assert_eq!(
215            format_timestamp_naive(reference, timestamp, test_timezone(), true),
216            "03:30 pm"
217        );
218    }
219
220    #[test]
221    fn test_format_yesterday() {
222        let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
223        let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0);
224
225        assert_eq!(
226            format_timestamp_naive(reference, timestamp, test_timezone(), true),
227            "yesterday at 09:00 am"
228        );
229    }
230
231    #[test]
232    fn test_format_yesterday_less_than_24_hours_ago() {
233        let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
234        let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0);
235
236        assert_eq!(
237            format_timestamp_naive(reference, timestamp, test_timezone(), true),
238            "yesterday at 08:00 pm"
239        );
240    }
241
242    #[test]
243    fn test_format_yesterday_more_than_24_hours_ago() {
244        let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
245        let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0);
246
247        assert_eq!(
248            format_timestamp_naive(reference, timestamp, test_timezone(), true),
249            "yesterday at 06:00 pm"
250        );
251    }
252
253    #[test]
254    fn test_format_yesterday_over_midnight() {
255        let reference = create_offset_datetime(1990, 4, 12, 0, 5, 0);
256        let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0);
257
258        assert_eq!(
259            format_timestamp_naive(reference, timestamp, test_timezone(), true),
260            "yesterday at 11:55 pm"
261        );
262    }
263
264    #[test]
265    fn test_format_yesterday_over_month() {
266        let reference = create_offset_datetime(1990, 4, 2, 9, 0, 0);
267        let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0);
268
269        assert_eq!(
270            format_timestamp_naive(reference, timestamp, test_timezone(), true),
271            "yesterday at 08:00 pm"
272        );
273    }
274
275    #[test]
276    fn test_format_before_yesterday() {
277        let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
278        let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0);
279
280        assert_eq!(
281            format_timestamp_naive(reference, timestamp, test_timezone(), true),
282            "04/10/1990"
283        );
284    }
285
286    fn test_timezone() -> UtcOffset {
287        UtcOffset::from_hms(0, 0, 0).expect("Valid timezone offset")
288    }
289
290    fn create_offset_datetime(
291        year: i32,
292        month: u8,
293        day: u8,
294        hour: u8,
295        minute: u8,
296        second: u8,
297    ) -> OffsetDateTime {
298        let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
299            .unwrap();
300        let time = time::Time::from_hms(hour, minute, second).unwrap();
301        date.with_time(time).assume_utc() // Assume UTC for simplicity
302    }
303}