notification panel: rework time formatting (#8997)

Bennet Bo Fenner and Evren Sen created

Follow up of #7994 to rework the notification panel timestamps.
This PR also includes some of the changes @evrsen proposed in #8996 
Here is what it looks like now:


https://github.com/zed-industries/zed/assets/53836821/d85450e7-eab6-4fe7-bd11-1d76c0e87258

Release Notes:
- Reworked date time formatting in the chat and the notification panel
- Added hover style to notifications and hovering tooltip on timestamps

---------

Co-authored-by: Evren Sen <146845123+evrsen@users.noreply.github.com>

Change summary

Cargo.lock                                 |   1 
crates/collab_ui/src/chat_panel.rs         |   3 
crates/collab_ui/src/notification_panel.rs |  60 +-
crates/time_format/Cargo.toml              |   1 
crates/time_format/src/time_format.rs      | 462 ++++++++++++++++++++---
5 files changed, 423 insertions(+), 104 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -9947,7 +9947,6 @@ dependencies = [
 name = "time_format"
 version = "0.1.0"
 dependencies = [
- "anyhow",
  "core-foundation",
  "core-foundation-sys 0.8.6",
  "sys-locale",

crates/collab_ui/src/chat_panel.rs 🔗

@@ -493,9 +493,10 @@ impl ChatPanel {
                                 )
                                 .child(
                                     Label::new(time_format::format_localized_timestamp(
-                                        OffsetDateTime::now_utc(),
                                         message.timestamp,
+                                        OffsetDateTime::now_utc(),
                                         self.local_timezone,
+                                        time_format::TimestampFormat::EnhancedAbsolute,
                                     ))
                                     .size(LabelSize::Small)
                                     .color(Color::Muted),

crates/collab_ui/src/notification_panel.rs 🔗

@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use std::{sync::Arc, time::Duration};
 use time::{OffsetDateTime, UtcOffset};
-use ui::{h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label};
+use ui::{h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tooltip};
 use util::{ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
@@ -228,6 +228,20 @@ impl NotificationPanel {
             self.did_render_notification(notification_id, &notification, cx);
         }
 
+        let relative_timestamp = time_format::format_localized_timestamp(
+            timestamp,
+            now,
+            self.local_timezone,
+            time_format::TimestampFormat::Relative,
+        );
+
+        let absolute_timestamp = time_format::format_localized_timestamp(
+            timestamp,
+            now,
+            self.local_timezone,
+            time_format::TimestampFormat::Absolute,
+        );
+
         Some(
             div()
                 .id(ix)
@@ -237,6 +251,7 @@ impl NotificationPanel {
                 .px_2()
                 .py_1()
                 .gap_2()
+                .hover(|style| style.bg(cx.theme().colors().element_hover))
                 .when(can_navigate, |el| {
                     el.cursor(CursorStyle::PointingHand).on_click({
                         let notification = notification.clone();
@@ -261,12 +276,17 @@ impl NotificationPanel {
                         .child(
                             h_flex()
                                 .child(
-                                    Label::new(format_timestamp(
-                                        timestamp,
-                                        now,
-                                        self.local_timezone,
-                                    ))
-                                    .color(Color::Muted),
+                                    div()
+                                        .id("notification_timestamp")
+                                        .hover(|style| {
+                                            style
+                                                .bg(cx.theme().colors().element_selected)
+                                                .rounded_md()
+                                        })
+                                        .child(Label::new(relative_timestamp).color(Color::Muted))
+                                        .tooltip(move |cx| {
+                                            Tooltip::text(absolute_timestamp.clone(), cx)
+                                        }),
                                 )
                                 .children(if let Some(is_accepted) = response {
                                     Some(div().flex().flex_grow().justify_end().child(Label::new(
@@ -757,29 +777,3 @@ impl Render for NotificationToast {
 }
 
 impl EventEmitter<DismissEvent> for NotificationToast {}
-
-fn format_timestamp(
-    mut timestamp: OffsetDateTime,
-    mut now: OffsetDateTime,
-    local_timezone: UtcOffset,
-) -> String {
-    timestamp = timestamp.to_offset(local_timezone);
-    now = now.to_offset(local_timezone);
-
-    let today = now.date();
-    let date = timestamp.date();
-    if date == today {
-        let difference = now - timestamp;
-        if difference >= Duration::from_secs(3600) {
-            format!("{}h", difference.whole_seconds() / 3600)
-        } else if difference >= Duration::from_secs(60) {
-            format!("{}m", difference.whole_seconds() / 60)
-        } else {
-            "just now".to_string()
-        }
-    } else if date.next_day() == Some(today) {
-        "yesterday".to_string()
-    } else {
-        format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
-    }
-}

crates/time_format/Cargo.toml 🔗

@@ -13,7 +13,6 @@ path = "src/time_format.rs"
 doctest = false
 
 [dependencies]
-anyhow.workspace = true
 sys-locale.workspace = true
 time.workspace = true
 

crates/time_format/src/time_format.rs 🔗

@@ -1,48 +1,156 @@
-use std::sync::OnceLock;
-
 use time::{OffsetDateTime, UtcOffset};
 
+/// The formatting style for a timestamp.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TimestampFormat {
+    /// Formats the timestamp as an absolute time, e.g. "2021-12-31 3:00AM".
+    Absolute,
+    /// Formats the timestamp as an absolute time.
+    /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
+    /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
+    EnhancedAbsolute,
+    /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
+    Relative,
+}
+
 /// Formats a timestamp, which respects the user's date and time preferences/custom format.
 pub fn format_localized_timestamp(
-    reference: OffsetDateTime,
     timestamp: OffsetDateTime,
+    reference: OffsetDateTime,
     timezone: UtcOffset,
+    format: TimestampFormat,
+) -> String {
+    let timestamp_local = timestamp.to_offset(timezone);
+    let reference_local = reference.to_offset(timezone);
+
+    match format {
+        TimestampFormat::Absolute => {
+            format_absolute_timestamp(timestamp_local, reference_local, false)
+        }
+        TimestampFormat::EnhancedAbsolute => {
+            format_absolute_timestamp(timestamp_local, reference_local, true)
+        }
+        TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
+            .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
+    }
+}
+
+fn format_absolute_timestamp(
+    timestamp: OffsetDateTime,
+    reference: OffsetDateTime,
+    #[allow(unused_variables)] enhanced_date_formatting: bool,
 ) -> String {
     #[cfg(target_os = "macos")]
     {
-        let timestamp_local = timestamp.to_offset(timezone);
-        let reference_local = reference.to_offset(timezone);
-        let reference_local_date = reference_local.date();
-        let timestamp_local_date = timestamp_local.date();
-
-        let native_fmt = if timestamp_local_date == reference_local_date {
-            macos::format_time(&timestamp)
-        } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
-            macos::format_time(&timestamp).map(|t| format!("yesterday at {}", t).to_string())
+        if !enhanced_date_formatting {
+            return format!(
+                "{} {}",
+                macos::format_date(&timestamp),
+                macos::format_time(&timestamp)
+            );
+        }
+
+        let timestamp_date = timestamp.date();
+        let reference_date = reference.date();
+        if timestamp_date == reference_date {
+            format!("Today at {}", macos::format_time(&timestamp))
+        } else if reference_date.previous_day() == Some(timestamp_date) {
+            format!("Yesterday at {}", macos::format_time(&timestamp))
         } else {
-            macos::format_date(&timestamp)
-        };
-        native_fmt.unwrap_or_else(|_| format_timestamp_fallback(reference, timestamp, timezone))
+            format!(
+                "{} {}",
+                macos::format_date(&timestamp),
+                macos::format_time(&timestamp)
+            )
+        }
     }
     #[cfg(not(target_os = "macos"))]
     {
         // todo(linux) respect user's date/time preferences
         // todo(windows) respect user's date/time preferences
-        format_timestamp_fallback(reference, timestamp, timezone)
+        format_timestamp_fallback(timestamp, reference)
     }
 }
 
-fn format_timestamp_fallback(
-    reference: OffsetDateTime,
-    timestamp: OffsetDateTime,
-    timezone: UtcOffset,
-) -> String {
-    static CURRENT_LOCALE: OnceLock<String> = OnceLock::new();
-    let current_locale = CURRENT_LOCALE
-        .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
+fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
+    let difference = reference - timestamp;
+    let minutes = difference.whole_minutes();
+    match minutes {
+        0 => Some("Just now".to_string()),
+        1 => Some("1 minute ago".to_string()),
+        2..=59 => Some(format!("{} minutes ago", minutes)),
+        _ => {
+            let hours = difference.whole_hours();
+            match hours {
+                1 => Some("1 hour ago".to_string()),
+                2..=23 => Some(format!("{} hours ago", hours)),
+                _ => None,
+            }
+        }
+    }
+}
 
-    let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str());
-    format_timestamp_naive(reference, timestamp, timezone, is_12_hour_time)
+fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
+    let timestamp_date = timestamp.date();
+    let reference_date = reference.date();
+    let difference = reference_date - timestamp_date;
+    let days = difference.whole_days();
+    match days {
+        0 => "Today".to_string(),
+        1 => "Yesterday".to_string(),
+        2..=6 => format!("{} days ago", days),
+        _ => {
+            let weeks = difference.whole_weeks();
+            match weeks {
+                1 => "1 week ago".to_string(),
+                2..=4 => format!("{} weeks ago", weeks),
+                _ => {
+                    let month_diff = calculate_month_difference(timestamp, reference);
+                    match month_diff {
+                        0..=1 => "1 month ago".to_string(),
+                        2..=11 => format!("{} months ago", month_diff),
+                        _ => {
+                            let timestamp_year = timestamp_date.year();
+                            let reference_year = reference_date.year();
+                            let years = reference_year - timestamp_year;
+                            match years {
+                                1 => "1 year ago".to_string(),
+                                _ => format!("{} years ago", years),
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// Calculates the difference in months between two timestamps.
+/// The reference timestamp should always be greater than the timestamp.
+fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize {
+    let timestamp_year = timestamp.year();
+    let reference_year = reference.year();
+    let timestamp_month: u8 = timestamp.month().into();
+    let reference_month: u8 = reference.month().into();
+
+    let month_diff = if reference_month >= timestamp_month {
+        reference_month as usize - timestamp_month as usize
+    } else {
+        12 - timestamp_month as usize + reference_month as usize
+    };
+
+    let year_diff = (reference_year - timestamp_year) as usize;
+    if year_diff == 0 {
+        reference_month as usize - timestamp_month as usize
+    } else {
+        if month_diff == 0 {
+            year_diff * 12
+        } else if timestamp_month > reference_month {
+            (year_diff - 1) * 12 + month_diff
+        } else {
+            year_diff * 12 + month_diff
+        }
+    }
 }
 
 /// Formats a timestamp, which is either in 12-hour or 24-hour time format.
@@ -50,20 +158,20 @@ fn format_timestamp_fallback(
 /// This function does not respect the user's date and time preferences.
 /// This should only be used as a fallback mechanism when the OS time formatting fails.
 pub fn format_timestamp_naive(
-    reference: OffsetDateTime,
-    timestamp: OffsetDateTime,
-    timezone: UtcOffset,
+    timestamp_local: OffsetDateTime,
+    reference_local: OffsetDateTime,
     is_12_hour_time: bool,
 ) -> String {
-    let timestamp_local = timestamp.to_offset(timezone);
     let timestamp_local_hour = timestamp_local.hour();
     let timestamp_local_minute = timestamp_local.minute();
+    let reference_local_date = reference_local.date();
+    let timestamp_local_date = timestamp_local.date();
 
     let (hour, meridiem) = if is_12_hour_time {
         let meridiem = if timestamp_local_hour >= 12 {
-            "pm"
+            "PM"
         } else {
-            "am"
+            "AM"
         };
 
         let hour_12 = match timestamp_local_hour {
@@ -78,23 +186,11 @@ pub fn format_timestamp_naive(
     };
 
     let formatted_time = match meridiem {
-        Some(meridiem) => format!("{:02}:{:02} {}", hour, timestamp_local_minute, meridiem),
+        Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem),
         None => format!("{:02}:{:02}", hour, timestamp_local_minute),
     };
 
-    let reference_local = reference.to_offset(timezone);
-    let reference_local_date = reference_local.date();
-    let timestamp_local_date = timestamp_local.date();
-
-    if timestamp_local_date == reference_local_date {
-        return formatted_time;
-    }
-
-    if reference_local_date.previous_day() == Some(timestamp_local_date) {
-        return format!("yesterday at {}", formatted_time);
-    }
-
-    match meridiem {
+    let formatted_date = match meridiem {
         Some(_) => format!(
             "{:02}/{:02}/{}",
             timestamp_local_date.month() as u32,
@@ -107,9 +203,28 @@ pub fn format_timestamp_naive(
             timestamp_local_date.month() as u32,
             timestamp_local_date.year()
         ),
+    };
+
+    if timestamp_local_date == reference_local_date {
+        format!("Today at {}", formatted_time)
+    } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
+        format!("Yesterday at {}", formatted_time)
+    } else {
+        format!("{} {}", formatted_date, formatted_time)
     }
 }
 
+#[cfg(not(target_os = "macos"))]
+fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
+    static CURRENT_LOCALE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
+    let current_locale = CURRENT_LOCALE
+        .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
+
+    let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str());
+    format_timestamp_naive(timestamp, reference, is_12_hour_time)
+}
+
+#[cfg(not(target_os = "macos"))]
 /// Returns `true` if the locale is recognized as a 12-hour time locale.
 fn is_12_hour_time_by_locale(locale: &str) -> bool {
     [
@@ -129,7 +244,6 @@ fn is_12_hour_time_by_locale(locale: &str) -> bool {
 
 #[cfg(target_os = "macos")]
 mod macos {
-    use anyhow::Result;
     use core_foundation::base::TCFType;
     use core_foundation::date::CFAbsoluteTime;
     use core_foundation::string::CFString;
@@ -144,18 +258,18 @@ mod macos {
         locale::CFLocaleCopyCurrent,
     };
 
-    pub fn format_time(timestamp: &time::OffsetDateTime) -> Result<String> {
+    pub fn format_time(timestamp: &time::OffsetDateTime) -> String {
         format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f))
     }
 
-    pub fn format_date(timestamp: &time::OffsetDateTime) -> Result<String> {
+    pub fn format_date(timestamp: &time::OffsetDateTime) -> String {
         format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
     }
 
     fn format_with_date_formatter(
         timestamp: &time::OffsetDateTime,
         fmt: CFDateFormatterRef,
-    ) -> Result<String> {
+    ) -> String {
         const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200;
         // Convert timestamp to macOS absolute time
         let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET;
@@ -166,7 +280,7 @@ mod macos {
                 fmt,
                 cf_absolute_time,
             );
-            Ok(CFString::wrap_under_create_rule(s).to_string())
+            CFString::wrap_under_create_rule(s).to_string()
         }
     }
 
@@ -201,8 +315,8 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), false),
-            "15:30"
+            format_timestamp_naive(timestamp, reference, false),
+            "Today at 15:30"
         );
     }
 
@@ -212,8 +326,8 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), true),
-            "03:30 pm"
+            format_timestamp_naive(timestamp, reference, true),
+            "Today at 3:30 PM"
         );
     }
 
@@ -223,8 +337,8 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), true),
-            "yesterday at 09:00 am"
+            format_timestamp_naive(timestamp, reference, true),
+            "Yesterday at 9:00 AM"
         );
     }
 
@@ -234,8 +348,8 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), true),
-            "yesterday at 08:00 pm"
+            format_timestamp_naive(timestamp, reference, true),
+            "Yesterday at 8:00 PM"
         );
     }
 
@@ -245,8 +359,8 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), true),
-            "yesterday at 06:00 pm"
+            format_timestamp_naive(timestamp, reference, true),
+            "Yesterday at 6:00 PM"
         );
     }
 
@@ -256,8 +370,8 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), true),
-            "yesterday at 11:55 pm"
+            format_timestamp_naive(timestamp, reference, true),
+            "Yesterday at 11:55 PM"
         );
     }
 
@@ -267,8 +381,8 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), true),
-            "yesterday at 08:00 pm"
+            format_timestamp_naive(timestamp, reference, true),
+            "Yesterday at 8:00 PM"
         );
     }
 
@@ -278,8 +392,219 @@ mod tests {
         let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0);
 
         assert_eq!(
-            format_timestamp_naive(reference, timestamp, test_timezone(), true),
-            "04/10/1990"
+            format_timestamp_naive(timestamp, reference, true),
+            "04/10/1990 8:20 PM"
+        );
+    }
+
+    #[test]
+    fn test_relative_format_minutes() {
+        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
+        let mut current_timestamp = reference;
+
+        let mut next_minute = || {
+            current_timestamp = if current_timestamp.minute() == 0 {
+                current_timestamp
+                    .replace_hour(current_timestamp.hour() - 1)
+                    .unwrap()
+                    .replace_minute(59)
+                    .unwrap()
+            } else {
+                current_timestamp
+                    .replace_minute(current_timestamp.minute() - 1)
+                    .unwrap()
+            };
+            current_timestamp
+        };
+
+        assert_eq!(
+            format_relative_time(reference, reference),
+            Some("Just now".to_string())
+        );
+
+        assert_eq!(
+            format_relative_time(next_minute(), reference),
+            Some("1 minute ago".to_string())
+        );
+
+        for i in 2..=59 {
+            assert_eq!(
+                format_relative_time(next_minute(), reference),
+                Some(format!("{} minutes ago", i))
+            );
+        }
+
+        assert_eq!(
+            format_relative_time(next_minute(), reference),
+            Some("1 hour ago".to_string())
+        );
+    }
+
+    #[test]
+    fn test_relative_format_hours() {
+        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
+        let mut current_timestamp = reference;
+
+        let mut next_hour = || {
+            current_timestamp = if current_timestamp.hour() == 0 {
+                let date = current_timestamp.date().previous_day().unwrap();
+                current_timestamp.replace_date(date)
+            } else {
+                current_timestamp
+                    .replace_hour(current_timestamp.hour() - 1)
+                    .unwrap()
+            };
+            current_timestamp
+        };
+
+        assert_eq!(
+            format_relative_time(next_hour(), reference),
+            Some("1 hour ago".to_string())
+        );
+
+        for i in 2..=23 {
+            assert_eq!(
+                format_relative_time(next_hour(), reference),
+                Some(format!("{} hours ago", i))
+            );
+        }
+
+        assert_eq!(format_relative_time(next_hour(), reference), None);
+    }
+
+    #[test]
+    fn test_relative_format_days() {
+        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
+        let mut current_timestamp = reference;
+
+        let mut next_day = || {
+            let date = current_timestamp.date().previous_day().unwrap();
+            current_timestamp = current_timestamp.replace_date(date);
+            current_timestamp
+        };
+
+        assert_eq!(
+            format_relative_date(reference, reference),
+            "Today".to_string()
+        );
+
+        assert_eq!(
+            format_relative_date(next_day(), reference),
+            "Yesterday".to_string()
+        );
+
+        for i in 2..=6 {
+            assert_eq!(
+                format_relative_date(next_day(), reference),
+                format!("{} days ago", i)
+            );
+        }
+
+        assert_eq!(format_relative_date(next_day(), reference), "1 week ago");
+    }
+
+    #[test]
+    fn test_relative_format_weeks() {
+        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
+        let mut current_timestamp = reference;
+
+        let mut next_week = || {
+            for _ in 0..7 {
+                let date = current_timestamp.date().previous_day().unwrap();
+                current_timestamp = current_timestamp.replace_date(date);
+            }
+            current_timestamp
+        };
+
+        assert_eq!(
+            format_relative_date(next_week(), reference),
+            "1 week ago".to_string()
+        );
+
+        for i in 2..=4 {
+            assert_eq!(
+                format_relative_date(next_week(), reference),
+                format!("{} weeks ago", i)
+            );
+        }
+
+        assert_eq!(format_relative_date(next_week(), reference), "1 month ago");
+    }
+
+    #[test]
+    fn test_relative_format_months() {
+        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
+        let mut current_timestamp = reference;
+
+        let mut next_month = || {
+            if current_timestamp.month() == time::Month::January {
+                current_timestamp = current_timestamp
+                    .replace_month(time::Month::December)
+                    .unwrap()
+                    .replace_year(current_timestamp.year() - 1)
+                    .unwrap();
+            } else {
+                current_timestamp = current_timestamp
+                    .replace_month(current_timestamp.month().previous())
+                    .unwrap();
+            }
+            current_timestamp
+        };
+
+        assert_eq!(
+            format_relative_date(next_month(), reference),
+            "4 weeks ago".to_string()
+        );
+
+        for i in 2..=11 {
+            assert_eq!(
+                format_relative_date(next_month(), reference),
+                format!("{} months ago", i)
+            );
+        }
+
+        assert_eq!(format_relative_date(next_month(), reference), "1 year ago");
+    }
+
+    #[test]
+    fn test_calculate_month_difference() {
+        let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
+
+        assert_eq!(calculate_month_difference(reference, reference), 0);
+
+        assert_eq!(
+            calculate_month_difference(create_offset_datetime(1990, 1, 12, 23, 0, 0), reference),
+            3
+        );
+
+        assert_eq!(
+            calculate_month_difference(create_offset_datetime(1989, 11, 12, 23, 0, 0), reference),
+            5
+        );
+
+        assert_eq!(
+            calculate_month_difference(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference),
+            12
+        );
+
+        assert_eq!(
+            calculate_month_difference(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference),
+            13
+        );
+
+        assert_eq!(
+            calculate_month_difference(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference),
+            35
+        );
+
+        assert_eq!(
+            calculate_month_difference(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference),
+            36
+        );
+
+        assert_eq!(
+            calculate_month_difference(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference),
+            37
         );
     }
 
@@ -298,6 +623,7 @@ mod tests {
         let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
             .unwrap();
         let time = time::Time::from_hms(hour, minute, second).unwrap();
-        date.with_time(time).assume_utc() // Assume UTC for simplicity
+        let date = date.with_time(time).assume_utc(); // Assume UTC for simplicity
+        date.to_offset(test_timezone())
     }
 }