time_format.rs

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