feat(timeago): add format_time_html returning a <time> element
Wrap humanize_delta in an HTML <time> element with datetime= and title= attributes carrying the precise UTC value, so hovering surfaces the exact timestamp regardless of the relative label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
237f26e5cb
commit
1926fe895c
2 changed files with 51 additions and 1 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
|
|
||||||
_MONTHS = (
|
_MONTHS = (
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
|
@ -51,3 +53,18 @@ def humanize_delta(then: datetime, now: datetime | None = None) -> str:
|
||||||
return _format_date(then, now)
|
return _format_date(then, now)
|
||||||
|
|
||||||
return _relative_label(abs_seconds, past=(delta_seconds > 0))
|
return _relative_label(abs_seconds, past=(delta_seconds > 0))
|
||||||
|
|
||||||
|
|
||||||
|
def format_time_html(then: datetime, now: datetime | None = None) -> Markup:
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
then_utc = _ensure_utc(then).astimezone(UTC)
|
||||||
|
now = _ensure_utc(now)
|
||||||
|
|
||||||
|
label = humanize_delta(then_utc, now=now)
|
||||||
|
iso = then_utc.isoformat()
|
||||||
|
title = then_utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
return Markup(
|
||||||
|
f'<time datetime="{escape(iso)}" title="{escape(title)}">'
|
||||||
|
f"{escape(label)}</time>"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from l4d2web.services.timeago import humanize_delta
|
from l4d2web.services.timeago import format_time_html, humanize_delta
|
||||||
|
|
||||||
|
|
||||||
NOW = datetime(2026, 5, 16, 12, 0, 0, tzinfo=UTC)
|
NOW = datetime(2026, 5, 16, 12, 0, 0, tzinfo=UTC)
|
||||||
|
|
@ -82,3 +83,35 @@ def test_humanize_delta_year_boundary_includes_year_when_years_differ():
|
||||||
now = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC)
|
now = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC)
|
||||||
then = datetime(2025, 11, 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"
|
assert humanize_delta(then, now=now) == "15 Nov 2025"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_returns_markup():
|
||||||
|
then = NOW - timedelta(minutes=5)
|
||||||
|
out = format_time_html(then, now=NOW)
|
||||||
|
assert isinstance(out, Markup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_contains_time_element_with_attrs():
|
||||||
|
then = datetime(2026, 5, 16, 14, 32, 11, tzinfo=UTC)
|
||||||
|
now = then + timedelta(minutes=5)
|
||||||
|
out = str(format_time_html(then, now=now))
|
||||||
|
assert out.startswith("<time ")
|
||||||
|
assert out.endswith("</time>")
|
||||||
|
assert 'datetime="2026-05-16T14:32:11+00:00"' in out
|
||||||
|
assert 'title="2026-05-16 14:32:11 UTC"' in out
|
||||||
|
assert ">5 minutes ago<" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_label_matches_humanize_delta():
|
||||||
|
then = NOW - timedelta(hours=2)
|
||||||
|
label = humanize_delta(then, now=NOW)
|
||||||
|
out = str(format_time_html(then, now=NOW))
|
||||||
|
assert f">{label}<" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_normalises_naive_input_to_utc():
|
||||||
|
then_naive = datetime(2026, 5, 16, 14, 32, 11)
|
||||||
|
now = datetime(2026, 5, 16, 14, 37, 11, tzinfo=UTC)
|
||||||
|
out = str(format_time_html(then_naive, now=now))
|
||||||
|
assert 'datetime="2026-05-16T14:32:11+00:00"' in out
|
||||||
|
assert 'title="2026-05-16 14:32:11 UTC"' in out
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue