4.5 KiB · text History 7a00c02
//! Custom minijinja filters.
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use minijinja::{Error, ErrorKind};

/// Django's classic `|timesince`: how long ago `value` was, as the largest
/// non-zero unit plus the next one down when that's non-zero too — "3 days",
/// "2 weeks, 1 day", "1 year, 2 months". Years are 365 days and months 30,
/// as in Django before calendar-aware months.
///
/// `value` (and the optional `now`, for comparing two times) is a date
/// (`2026-10-20`) or a datetime (RFC 3339, or naive `2026-10-20T12:00:00`).
/// Dates count in whole days, so a date today is "0 days" rather than the
/// hours since midnight. Anything in the future is "0 minutes", or "0 days"
/// for a date, as in Django.
pub fn timesince(value: String, now: Option<String>) -> Result<String, Error> {
    let (then, date_only) = parse(&value)?;
    let now = match now {
        Some(n) => parse(&n)?.0,
        None => {
            if date_only {
                Utc::now().date_naive().and_hms_opt(0, 0, 0).expect("midnight").and_utc()
            } else {
                Utc::now()
            }
        }
    };
    Ok(since((now - then).num_seconds(), date_only))
}

fn parse(s: &str) -> Result<(DateTime<Utc>, bool), Error> {
    let s = s.trim();
    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
        return Ok((d.and_hms_opt(0, 0, 0).expect("midnight").and_utc(), true));
    }
    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
        return Ok((dt.with_timezone(&Utc), false));
    }
    for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M"] {
        if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
            return Ok((dt.and_utc(), false));
        }
    }
    Err(Error::new(ErrorKind::InvalidOperation, format!("timesince: not a date or datetime: {s:?}")))
}

const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const CHUNKS: [(i64, &str); 6] = [
    (365 * DAY, "year"),
    (30 * DAY, "month"),
    (7 * DAY, "week"),
    (DAY, "day"),
    (HOUR, "hour"),
    (MINUTE, "minute"),
];

fn unit(count: i64, name: &str) -> String {
    format!("{count} {name}{}", if count == 1 { "" } else { "s" })
}

fn since(seconds: i64, date_only: bool) -> String {
    // Dates never resolve below a day.
    let chunks = if date_only { &CHUNKS[..4] } else { &CHUNKS[..] };
    let smallest = chunks[chunks.len() - 1].1;
    if seconds <= 0 {
        return unit(0, smallest);
    }
    let Some(i) = chunks.iter().position(|(s, _)| seconds / s != 0) else {
        return unit(0, smallest);
    };
    let (size, name) = chunks[i];
    let count = seconds / size;
    let mut out = unit(count, name);
    if let Some((size2, name2)) = chunks.get(i + 1) {
        let count2 = (seconds - size * count) / size2;
        if count2 != 0 {
            out.push_str(", ");
            out.push_str(&unit(count2, name2));
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ts(value: &str, now: &str) -> String {
        timesince(value.into(), Some(now.into())).unwrap()
    }

    #[test]
    fn dates_count_whole_days() {
        assert_eq!(ts("2026-10-28", "2026-10-28"), "0 days");
        assert_eq!(ts("2026-10-27", "2026-10-28"), "1 day");
        assert_eq!(ts("2026-10-20", "2026-10-28"), "1 week, 1 day");
        assert_eq!(ts("2026-10-14", "2026-10-28"), "2 weeks");
        // 88 days: two 30-day months, then 28 days.
        assert_eq!(ts("2026-08-01", "2026-10-28"), "2 months, 4 weeks");
        // 787 days: two 365-day years, then 57 days.
        assert_eq!(ts("2024-09-01", "2026-10-28"), "2 years, 1 month");
        // 757 days: the remainder is under a month, so years alone.
        assert_eq!(ts("2024-10-01", "2026-10-28"), "2 years");
        // Future: zero, as Django does.
        assert_eq!(ts("2026-11-01", "2026-10-28"), "0 days");
    }

    #[test]
    fn datetimes_reach_minutes() {
        assert_eq!(ts("2026-10-28T11:15:00Z", "2026-10-28T12:00:00Z"), "45 minutes");
        assert_eq!(ts("2026-10-28T09:30:00Z", "2026-10-28T12:00:00Z"), "2 hours, 30 minutes");
        assert_eq!(ts("2026-10-28T11:59:30Z", "2026-10-28T12:00:00Z"), "0 minutes");
        // Offsets are honoured; naive datetimes are taken as UTC.
        assert_eq!(ts("2026-10-28T08:00:00-04:00", "2026-10-28 13:00:00"), "1 hour");
        // Only adjacent units: 1 day and 0 hours shows no minutes.
        assert_eq!(ts("2026-10-27T11:59:00Z", "2026-10-28T12:00:00Z"), "1 day");
    }

    #[test]
    fn rejects_non_dates() {
        assert!(timesince("yesterday".into(), None).is_err());
    }
}