//! 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());
}
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
4.5 KiB · text
History
7a00c02