use chrono::{DateTime, Utc};
const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const WEEK: i64 = 7 * DAY;
const MONTH: i64 = 30 * DAY;
const YEAR: i64 = 365 * DAY;
const CHUNKS: &[(i64, &str)] = &[
(YEAR, "year"),
(MONTH, "month"),
(WEEK, "week"),
(DAY, "day"),
(HOUR, "hour"),
(MINUTE, "minute"),
];
fn format_unit(n: i64, unit: &str) -> String {
let plural = if n == 1 { "" } else { "s" };
format!("{n} {unit}{plural}")
}
fn build(seconds: i64, depth: usize) -> String {
if seconds <= 0 {
return "0 minutes".to_owned();
}
let depth = depth.clamp(1, CHUNKS.len());
let mut remaining = seconds;
let mut parts: Vec<String> = Vec::with_capacity(depth);
let mut started = false;
for (size, name) in CHUNKS {
if !started {
if remaining >= *size {
let n = remaining / size;
remaining -= n * size;
parts.push(format_unit(n, name));
started = true;
}
continue;
}
if parts.len() >= depth || remaining < *size {
break;
}
let n = remaining / size;
remaining -= n * size;
parts.push(format_unit(n, name));
}
if parts.is_empty() {
return "0 minutes".to_owned();
}
parts.join(", ")
}
pub fn timesince(d: DateTime<Utc>, now: Option<DateTime<Utc>>, depth: usize) -> String {
let now = now.unwrap_or_else(Utc::now);
let delta = now.signed_duration_since(d).num_seconds();
build(delta, depth)
}
pub fn timeuntil(d: DateTime<Utc>, now: Option<DateTime<Utc>>, depth: usize) -> String {
let now = now.unwrap_or_else(Utc::now);
let delta = d.signed_duration_since(now).num_seconds();
build(delta, depth)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
fn t(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, mo, d, h, mi, s).unwrap()
}
#[test]
fn timesince_basic_depth_two() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(4 * DAY + 6 * HOUR);
assert_eq!(timesince(past, Some(now), 2), "4 days, 6 hours");
}
#[test]
fn timesince_depth_one_collapses() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(4 * DAY + 6 * HOUR);
assert_eq!(timesince(past, Some(now), 1), "4 days");
}
#[test]
fn timesince_singular_pluralization() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(DAY + HOUR);
assert_eq!(timesince(past, Some(now), 2), "1 day, 1 hour");
}
#[test]
fn timesince_stops_at_the_first_zero_unit() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(YEAR + 3 * DAY);
assert_eq!(timesince(past, Some(now), 2), "1 year");
let past = now - Duration::seconds(YEAR + MONTH + 3 * DAY);
assert_eq!(timesince(past, Some(now), 2), "1 year, 1 month");
}
#[test]
fn timesince_future_returns_zero() {
let now = t(2026, 6, 5, 12, 0, 0);
let future = now + Duration::seconds(3 * DAY);
assert_eq!(timesince(future, Some(now), 2), "0 minutes");
}
#[test]
fn timesince_sub_minute_returns_zero() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(30);
assert_eq!(timesince(past, Some(now), 2), "0 minutes");
}
#[test]
fn timeuntil_basic() {
let now = t(2026, 6, 5, 12, 0, 0);
let future = now + Duration::seconds(2 * HOUR + 30 * MINUTE);
assert_eq!(timeuntil(future, Some(now), 2), "2 hours, 30 minutes");
}
#[test]
fn timeuntil_past_returns_zero() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(2 * HOUR);
assert_eq!(timeuntil(past, Some(now), 2), "0 minutes");
}
#[test]
fn timesince_depth_clamps_high() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now
- Duration::seconds(2 * YEAR + 3 * MONTH + 2 * WEEK + 5 * DAY + 6 * HOUR + 7 * MINUTE);
assert_eq!(
timesince(past, Some(now), 99),
"2 years, 3 months, 2 weeks, 5 days, 6 hours, 7 minutes"
);
}
#[test]
fn timesince_depth_clamps_zero() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(2 * DAY + 5 * HOUR);
assert_eq!(timesince(past, Some(now), 0), "2 days");
}
#[test]
fn timesince_weeks_bucket() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(2 * WEEK + 3 * DAY);
assert_eq!(timesince(past, Some(now), 2), "2 weeks, 3 days");
}
#[test]
fn timesince_months_bucket() {
let now = t(2026, 6, 5, 12, 0, 0);
let past = now - Duration::seconds(2 * MONTH + WEEK + 5 * DAY);
assert_eq!(timesince(past, Some(now), 2), "2 months, 1 week");
}
#[test]
fn timesince_now_none_uses_utc_now() {
let past = Utc::now() - Duration::seconds(2 * DAY + 5 * HOUR);
let s = timesince(past, None, 2);
assert!(s.contains("day"));
}
}