feat(timeago): symmetric ladder with second precision and date fallback
Rewrite humanize_delta as a symmetric past/future ladder with sub-minute precision. Replace the bare ISO date fallback after 7 days with a day-month form (year suppressed when same as now). Refs spec docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fdcefcfec6
commit
237f26e5cb
2 changed files with 129 additions and 21 deletions
|
|
@ -1,29 +1,53 @@
|
||||||
from datetime import UTC, datetime
|
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:
|
def humanize_delta(then: datetime, now: datetime | None = None) -> str:
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
if then.tzinfo is None:
|
then = _ensure_utc(then)
|
||||||
then = then.replace(tzinfo=UTC)
|
now = _ensure_utc(now)
|
||||||
if now.tzinfo is None:
|
|
||||||
now = now.replace(tzinfo=UTC)
|
|
||||||
|
|
||||||
seconds = int((now - then).total_seconds())
|
delta_seconds = int((now - then).total_seconds())
|
||||||
if seconds < 0:
|
abs_seconds = abs(delta_seconds)
|
||||||
seconds = 0
|
|
||||||
|
|
||||||
if seconds < 45:
|
if abs_seconds == 0:
|
||||||
return "just now"
|
return "now"
|
||||||
if seconds < 90:
|
|
||||||
return "1 minute ago"
|
if abs_seconds >= 7 * 86400:
|
||||||
minutes = seconds // 60
|
return _format_date(then, now)
|
||||||
if minutes < 60:
|
|
||||||
return f"{minutes} minutes ago"
|
return _relative_label(abs_seconds, past=(delta_seconds > 0))
|
||||||
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()
|
|
||||||
|
|
|
||||||
84
l4d2web/tests/test_timeago.py
Normal file
84
l4d2web/tests/test_timeago.py
Normal file
|
|
@ -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"
|
||||||
Loading…
Reference in a new issue