//! Human-readable date/time formatting utilities. //! //! Provides functions that turn absolute timestamps into relative display //! strings for the UI (e.g. "today", "2d ago", "+3d", "Just now"). //! //! All functions accept an explicit `now` parameter so they are //! deterministic and easy to test. use chrono::{DateTime, Local, Utc}; /// Formats a date relative to now, bidirectional: "2d ago", "today", "tomorrow", "+3d", "Mar 15". /// /// Used for task due dates and similar bidirectional relative displays. pub fn format_relative_date(dt: DateTime, now: DateTime) -> String { let diff_days = (dt.date_naive() - now.date_naive()).num_days(); if diff_days < -1 { format!("{}d ago", -diff_days) } else if diff_days == -1 { "1d ago".to_string() } else if diff_days == 0 { "today".to_string() } else if diff_days == 1 { "tomorrow".to_string() } else if diff_days < 7 { format!("+{}d", diff_days) } else { dt.format("%b %d").to_string() } } /// Formats a future date relative to now: "today", "tomorrow", "+3d", "Mar 15". /// /// Used for snooze-until displays where the date is always in the future. pub fn format_relative_future(dt: DateTime, now: DateTime) -> String { let diff_days = (dt.date_naive() - now.date_naive()).num_days(); if diff_days == 0 { "today".to_string() } else if diff_days == 1 { "tomorrow".to_string() } else if diff_days < 7 { format!("+{}d", diff_days) } else { dt.format("%b %d").to_string() } } /// Formats a past timestamp as elapsed time: "Just now", "2h ago", "3d ago", "Mar 15". /// /// Used for email received_at displays. pub fn format_elapsed_time(dt: DateTime, now: DateTime) -> String { let diff = now.signed_duration_since(dt); let hours = diff.num_hours(); if hours < 1 { "Just now".to_string() } else if hours < 24 { format!("{}h ago", hours) } else { let days = diff.num_days(); if days < 7 { format!("{}d ago", days) } else { dt.with_timezone(&Local).format("%b %d").to_string() } } } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, NaiveDate, NaiveTime, TimeZone}; fn utc(year: i32, month: u32, day: u32, hour: u32) -> DateTime { let date = NaiveDate::from_ymd_opt(year, month, day).unwrap(); let time = NaiveTime::from_hms_opt(hour, 0, 0).unwrap(); Utc.from_utc_datetime(&date.and_time(time)) } // ---- format_relative_date ---- #[test] fn relative_date_today() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 15, 8); assert_eq!(format_relative_date(dt, now), "today"); } #[test] fn relative_date_tomorrow() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 16, 9); assert_eq!(format_relative_date(dt, now), "tomorrow"); } #[test] fn relative_date_yesterday() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 14, 12); assert_eq!(format_relative_date(dt, now), "1d ago"); } #[test] fn relative_date_multiple_days_ago() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 12, 12); assert_eq!(format_relative_date(dt, now), "3d ago"); } #[test] fn relative_date_few_days_future() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 18, 12); assert_eq!(format_relative_date(dt, now), "+3d"); } #[test] fn relative_date_far_future() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 5, 10, 12); assert_eq!(format_relative_date(dt, now), "May 10"); } #[test] fn relative_date_boundary_six_days() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 21, 12); assert_eq!(format_relative_date(dt, now), "+6d"); } #[test] fn relative_date_boundary_seven_days() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 22, 12); assert_eq!(format_relative_date(dt, now), "Apr 22"); } // ---- format_relative_future ---- #[test] fn relative_future_today() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 15, 17); assert_eq!(format_relative_future(dt, now), "today"); } #[test] fn relative_future_tomorrow() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 16, 9); assert_eq!(format_relative_future(dt, now), "tomorrow"); } #[test] fn relative_future_few_days() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 4, 19, 9); assert_eq!(format_relative_future(dt, now), "+4d"); } #[test] fn relative_future_far() { let now = utc(2026, 4, 15, 12); let dt = utc(2026, 6, 1, 9); assert_eq!(format_relative_future(dt, now), "Jun 01"); } // ---- format_elapsed_time ---- #[test] fn elapsed_just_now() { let now = utc(2026, 4, 15, 12); let dt = now - Duration::minutes(30); assert_eq!(format_elapsed_time(dt, now), "Just now"); } #[test] fn elapsed_hours() { let now = utc(2026, 4, 15, 12); let dt = now - Duration::hours(5); assert_eq!(format_elapsed_time(dt, now), "5h ago"); } #[test] fn elapsed_days() { let now = utc(2026, 4, 15, 12); let dt = now - Duration::days(3); assert_eq!(format_elapsed_time(dt, now), "3d ago"); } #[test] fn elapsed_weeks_shows_date() { let now = utc(2026, 4, 15, 12); let dt = now - Duration::days(14); // Exact format depends on local timezone, but should contain month abbreviation let result = format_elapsed_time(dt, now); assert!(result.contains("Apr") || result.contains("Mar"), "Expected month abbrev, got: {result}"); } #[test] fn elapsed_boundary_23h() { let now = utc(2026, 4, 15, 12); let dt = now - Duration::hours(23); assert_eq!(format_elapsed_time(dt, now), "23h ago"); } #[test] fn elapsed_boundary_24h() { let now = utc(2026, 4, 15, 12); let dt = now - Duration::hours(24); assert_eq!(format_elapsed_time(dt, now), "1d ago"); } }