use chrono::{DateTime, Utc};
pub fn format_age(then: DateTime<Utc>, now: DateTime<Utc>) -> String {
let secs = match (now - then).num_seconds() {
n if n < 0 => return "just now".to_string(),
n => n,
};
if secs < 60 {
return "just now".to_string();
}
let minutes = secs / 60;
if minutes < 60 {
return pluralize(minutes, "minute");
}
let hours = minutes / 60;
if hours < 24 {
return pluralize(hours, "hour");
}
let days = hours / 24;
if days < 7 {
return pluralize(days, "day");
}
if days < 30 {
return pluralize(days / 7, "week");
}
if days < 365 {
return pluralize(days / 30, "month");
}
pluralize(days / 365, "year")
}
fn pluralize(n: i64, unit: &str) -> String {
if n == 1 {
format!("1 {unit} ago")
} else {
format!("{n} {unit}s ago")
}
}
pub fn format_last_used_value(
has_metadata: bool,
last_used: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> Option<String> {
if !has_metadata {
return None;
}
Some(match last_used {
Some(t) => format_age(t, now),
None => "never".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn anchor() -> DateTime<Utc> {
"2026-06-02T12:00:00Z".parse().unwrap()
}
#[test]
fn just_now_for_under_a_minute() {
let now = anchor();
assert_eq!(format_age(now, now), "just now");
assert_eq!(format_age(now - Duration::seconds(59), now), "just now");
}
#[test]
fn minutes_singular_at_one() {
let now = anchor();
assert_eq!(format_age(now - Duration::seconds(60), now), "1 minute ago");
}
#[test]
fn minutes_plural_above_one() {
let now = anchor();
assert_eq!(format_age(now - Duration::minutes(2), now), "2 minutes ago");
assert_eq!(
format_age(now - Duration::minutes(59), now),
"59 minutes ago"
);
}
#[test]
fn hours_bucket_with_boundary() {
let now = anchor();
assert_eq!(format_age(now - Duration::minutes(60), now), "1 hour ago");
assert_eq!(format_age(now - Duration::hours(23), now), "23 hours ago");
}
#[test]
fn days_bucket_with_boundary() {
let now = anchor();
assert_eq!(format_age(now - Duration::hours(24), now), "1 day ago");
assert_eq!(format_age(now - Duration::days(6), now), "6 days ago");
}
#[test]
fn weeks_bucket_with_boundary() {
let now = anchor();
assert_eq!(format_age(now - Duration::days(7), now), "1 week ago");
assert_eq!(format_age(now - Duration::days(20), now), "2 weeks ago");
assert_eq!(format_age(now - Duration::days(29), now), "4 weeks ago");
}
#[test]
fn months_bucket_with_boundary() {
let now = anchor();
assert_eq!(format_age(now - Duration::days(30), now), "1 month ago");
assert_eq!(format_age(now - Duration::days(364), now), "12 months ago");
}
#[test]
fn years_bucket() {
let now = anchor();
assert_eq!(format_age(now - Duration::days(365), now), "1 year ago");
assert_eq!(format_age(now - Duration::days(730), now), "2 years ago");
}
#[test]
fn future_timestamps_flatten_to_just_now() {
let now = anchor();
assert_eq!(format_age(now + Duration::minutes(5), now), "just now");
assert_eq!(format_age(now + Duration::days(1), now), "just now");
}
#[test]
fn last_used_value_none_when_no_metadata() {
let now = anchor();
assert_eq!(
format_last_used_value(false, None, now),
None,
"no metadata → row must be hidden"
);
assert_eq!(
format_last_used_value(false, Some(now - Duration::hours(1)), now),
None,
"no metadata wins even if a stray last_used is passed",
);
}
#[test]
fn last_used_value_never_when_metadata_but_no_last_used() {
let now = anchor();
assert_eq!(
format_last_used_value(true, None, now),
Some("never".to_string())
);
}
#[test]
fn last_used_value_formats_age_when_present() {
let now = anchor();
let then = now - Duration::hours(3);
assert_eq!(
format_last_used_value(true, Some(then), now),
Some("3 hours ago".to_string())
);
}
#[test]
fn pluralization_at_every_singular_boundary() {
let now = anchor();
for s in [
"1 minute ago",
"1 hour ago",
"1 day ago",
"1 week ago",
"1 month ago",
"1 year ago",
] {
let d = match s {
"1 minute ago" => Duration::minutes(1),
"1 hour ago" => Duration::hours(1),
"1 day ago" => Duration::days(1),
"1 week ago" => Duration::days(7),
"1 month ago" => Duration::days(30),
"1 year ago" => Duration::days(365),
_ => unreachable!(),
};
assert_eq!(format_age(now - d, now), s, "bucket mismatch for {s}");
}
}
}