diff --git a/l4d2web/l4d2web/services/timeago.py b/l4d2web/l4d2web/services/timeago.py index 5906b2e..c3face6 100644 --- a/l4d2web/l4d2web/services/timeago.py +++ b/l4d2web/l4d2web/services/timeago.py @@ -1,29 +1,53 @@ from datetime import UTC, datetime +_MONTHS = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +) + + +def _ensure_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt + + +def _format_date(then: datetime, now: datetime) -> str: + month = _MONTHS[then.month - 1] + if then.year == now.year: + return f"{then.day} {month}" + return f"{then.day} {month} {then.year}" + + +def _relative_label(seconds: int, past: bool) -> str: + if seconds < 60: + unit, n = "second", seconds + elif seconds < 3600: + unit, n = "minute", seconds // 60 + elif seconds < 86400: + unit, n = "hour", seconds // 3600 + else: + unit, n = "day", seconds // 86400 + plural = "" if n == 1 else "s" + if past: + return f"{n} {unit}{plural} ago" + return f"in {n} {unit}{plural}" + + def humanize_delta(then: datetime, now: datetime | None = None) -> str: if now is None: now = datetime.now(UTC) - if then.tzinfo is None: - then = then.replace(tzinfo=UTC) - if now.tzinfo is None: - now = now.replace(tzinfo=UTC) + then = _ensure_utc(then) + now = _ensure_utc(now) - seconds = int((now - then).total_seconds()) - if seconds < 0: - seconds = 0 + delta_seconds = int((now - then).total_seconds()) + abs_seconds = abs(delta_seconds) - if seconds < 45: - return "just now" - if seconds < 90: - return "1 minute ago" - minutes = seconds // 60 - if minutes < 60: - return f"{minutes} minutes ago" - hours = minutes // 60 - if hours < 24: - return "1 hour ago" if hours == 1 else f"{hours} hours ago" - days = hours // 24 - if days < 7: - return "1 day ago" if days == 1 else f"{days} days ago" - return then.date().isoformat() + if abs_seconds == 0: + return "now" + + if abs_seconds >= 7 * 86400: + return _format_date(then, now) + + return _relative_label(abs_seconds, past=(delta_seconds > 0)) diff --git a/l4d2web/tests/test_timeago.py b/l4d2web/tests/test_timeago.py new file mode 100644 index 0000000..274cbab --- /dev/null +++ b/l4d2web/tests/test_timeago.py @@ -0,0 +1,84 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from l4d2web.services.timeago import humanize_delta + + +NOW = datetime(2026, 5, 16, 12, 0, 0, tzinfo=UTC) + + +@pytest.mark.parametrize( + ("delta", "expected"), + [ + (timedelta(0), "now"), + (timedelta(seconds=1), "1 second ago"), + (timedelta(seconds=2), "2 seconds ago"), + (timedelta(seconds=59), "59 seconds ago"), + (timedelta(seconds=60), "1 minute ago"), + (timedelta(minutes=1), "1 minute ago"), + (timedelta(minutes=2), "2 minutes ago"), + (timedelta(minutes=59), "59 minutes ago"), + (timedelta(minutes=60), "1 hour ago"), + (timedelta(hours=1), "1 hour ago"), + (timedelta(hours=2), "2 hours ago"), + (timedelta(hours=23), "23 hours ago"), + (timedelta(hours=24), "1 day ago"), + (timedelta(days=1), "1 day ago"), + (timedelta(days=2), "2 days ago"), + (timedelta(days=6), "6 days ago"), + (timedelta(days=7), "9 May"), + (timedelta(days=30), "16 Apr"), + (timedelta(days=120), "16 Jan"), + (timedelta(days=365), "16 May 2025"), + (timedelta(days=400), "11 Apr 2025"), + ], +) +def test_humanize_delta_past(delta, expected): + then = NOW - delta + assert humanize_delta(then, now=NOW) == expected + + +@pytest.mark.parametrize( + ("delta", "expected"), + [ + (timedelta(seconds=1), "in 1 second"), + (timedelta(seconds=2), "in 2 seconds"), + (timedelta(seconds=59), "in 59 seconds"), + (timedelta(seconds=60), "in 1 minute"), + (timedelta(minutes=2), "in 2 minutes"), + (timedelta(minutes=59), "in 59 minutes"), + (timedelta(hours=1), "in 1 hour"), + (timedelta(hours=23), "in 23 hours"), + (timedelta(days=1), "in 1 day"), + (timedelta(days=6), "in 6 days"), + (timedelta(days=7), "23 May"), + (timedelta(days=30), "15 Jun"), + (timedelta(days=365), "16 May 2027"), + ], +) +def test_humanize_delta_future(delta, expected): + then = NOW + delta + assert humanize_delta(then, now=NOW) == expected + + +def test_humanize_delta_accepts_naive_input_as_utc(): + then_naive = (NOW - timedelta(minutes=5)).replace(tzinfo=None) + assert humanize_delta(then_naive, now=NOW) == "5 minutes ago" + + +def test_humanize_delta_accepts_naive_now_as_utc(): + then = NOW - timedelta(minutes=5) + now_naive = NOW.replace(tzinfo=None) + assert humanize_delta(then, now=now_naive) == "5 minutes ago" + + +def test_humanize_delta_default_now_is_datetime_now_utc(): + then = datetime.now(UTC) - timedelta(seconds=3) + assert humanize_delta(then) in {"3 seconds ago", "2 seconds ago", "4 seconds ago"} + + +def test_humanize_delta_year_boundary_includes_year_when_years_differ(): + now = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC) + then = datetime(2025, 11, 15, 12, 0, 0, tzinfo=UTC) + assert humanize_delta(then, now=now) == "15 Nov 2025"