Unify three coexisting time-display styles (raw datetime repr, bespoke inline math, route-side humanize_delta) behind a single timeago Jinja filter returning a <time> element with relative label and UTC tooltip. Symmetric past/future ladder with second precision and day-month-year fallback >7d. Naive-datetime DB-column cleanup tracked as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.7 KiB
Shared relative-time display helper (spec)
Brainstorm scratch artifact. On approval, copy to
docs/superpowers/specs/2026-05-16-timeago-shared-display-design.mdand commit (perAGENTS.mdplanning-artifact convention).
Context
left4me's web UI renders datetimes inconsistently. Three styles coexist:
- Raw Python
datetimerepr in admin/blueprint/job tables (admin_users.html,blueprints.html,_job_table.html,job_detail.html) — unfriendly for users. - Bespoke inline math in
templates/_live_state.html— three expressions of the form((now - x).total_seconds() // 60) | int }}m agowith their own vocabulary (12s ago,0m agoinside the first minute, no singular handling). - Centralised but Python-side
humanize_delta(inl4d2web/l4d2web/services/timeago.py) called frompage_routes.pyforlatest_job_whenandlatest_build_when. Correct output but only reachable by route-side precomputation, so adoption is friction-heavy.
The existing humanize_delta also has three design gaps that make it
unsuitable as the single source of truth:
- No sub-minute precision (under 45s it returns
just now, hiding the detail_live_state.htmlcares about). - Future timestamps are clamped to
just now(latent bug). - Falls back to a bare ISO date (
2025-04-21) after 7 days, which reads awkwardly next to relative siblings.
This spec unifies the three styles behind one Jinja filter, fixes the ladder, and leaves a known follow-up.
Goal
Every user-facing datetime in l4d2web templates flows through one
filter, {{ ts | timeago }}, producing a self-describing HTML <time>
element that combines a friendly relative label with a precise tooltip.
Contract
A single Jinja filter timeago returns a markupsafe.Markup wrapping
an HTML <time> element:
<time datetime="2026-05-16T14:32:11+00:00"
title="2026-05-16 14:32:11 UTC">5 minutes ago</time>
datetime=carries the ISO 8601 form with explicit timezone offset.title=is the hover tooltip — human-readable UTC string.- The text node is the relative label produced by the ladder below.
Ladder (long form, symmetric for past and future)
abs(delta) |
Past label | Future label |
|---|---|---|
0 (sub-second) |
now |
now |
< 60s |
1 second ago / N seconds ago |
in 1 second / in N seconds |
< 60m |
1 minute ago / N minutes ago |
in 1 minute / in N minutes |
< 24h |
1 hour ago / N hours ago |
in 1 hour / in N hours |
< 7d |
1 day ago / N days ago |
in 1 day / in N days |
≥ 7d, same year as now |
16 Feb |
30 May |
≥ 7d, different year |
16 May 2025 |
16 May 2027 |
- Singular form when the leading number is exactly
1. - Day-first English month abbreviation (
16 Feb, notFeb 16). - Year suppressed only when
then.year == now.year. then.dayrendered without leading zero (usethen.daydirectly, not%d, for portability).
Defensive input handling
then.tzinfo is None→ assume UTC (preserves current behaviour and works against today's naive-UTC SQLite columns; becomes a harmless no-op once the follow-up cleanup lands).nowdefaults todatetime.now(UTC)and is similarly normalised.Noneis not accepted — callers guard nullable columns with{% if x %}{{ x | timeago }}{% else %}-{% endif %}.
Module shape
File: l4d2web/l4d2web/services/timeago.py (existing).
Two callables, one source of truth for the ladder:
humanize_delta(then: datetime, now: datetime | None = None) -> str— pure text; ladder above. Kept for non-HTML callers (e.g. log lines, emails) and for unit-testing the label logic in isolation.format_time_html(then: datetime, now: datetime | None = None) -> Markup— wrapshumanize_deltain the<time>element. UTC-normalisesthenfor thedatetime=andtitle=attributes. Escapes the title viamarkupsafe.escape(defensive — ISO strings carry no HTML-special characters, but the explicit call is free and keeps the helper trustworthy if its inputs ever grow).
Register format_time_html as Jinja filter timeago in the Flask app
factory.
Files to modify
Helper + registration
l4d2web/l4d2web/services/timeago.py— rewritehumanize_deltato the symmetric ladder with sub-minute precision and future support; addformat_time_html.- Flask app factory (locate during implementation; likely
l4d2web/l4d2web/app.pyor__init__.py) — registerformat_time_htmlas filtertimeago:app.add_template_filter(format_time_html, "timeago").
Templates (replace raw datetimes / bespoke math)
l4d2web/l4d2web/templates/admin_users.html:25-26l4d2web/l4d2web/templates/blueprints.html:17-18l4d2web/l4d2web/templates/_job_table.html:22-23l4d2web/l4d2web/templates/job_detail.html:24-26l4d2web/l4d2web/templates/_live_state.html:10, 31, 55
For nullable columns (finished_at, started_at,
last_downloaded_at), preserve the current - placeholder:
{% if job.finished_at %}{{ job.finished_at | timeago }}{% else %}-{% endif %}
Routes
l4d2web/l4d2web/routes/page_routes.py:240, 294, 443, 475— drop the inlinehumanize_deltaimports andlatest_job_when/latest_build_whenprecomputation. Pass the rawdatetimeinto the template context (rename keys tolatest_job_at/latest_build_atfor type-honesty) and let the templates call| timeago.
_live_state.html now parameter
After the rewrite, check whether the now context variable is still
referenced anywhere in _live_state.html. If not, remove it from the
template render call (likely in page_routes.py and server_routes.py)
and from whichever helper builds the live-state context. Dead-code
cleanup, not behaviour change.
Files to read for context during implementation
l4d2web/l4d2web/services/timeago.py:1-29— current ladder, the function we're extending.l4d2web/l4d2web/models.py:22-23—now_utc()factory.l4d2web/l4d2web/routes/profile_routes.py:86-88— documented workaround comment that explains why naive datetimes exist (informs the defensivetzinfo is Noneline).l4d2web/l4d2web/routes/page_routes.py:240-294, 443-475— the existing precomputation pattern being removed.
Testing
Unit tests (l4d2web/tests/test_timeago.py, new file)
Parameterised boundary coverage for humanize_delta:
| Input delta | Expected |
|---|---|
0 |
now |
+1s past |
1 second ago |
+2s past |
2 seconds ago |
+59s past |
59 seconds ago |
+60s past |
1 minute ago |
+1m past |
1 minute ago |
+59m past |
59 minutes ago |
+60m past |
1 hour ago |
+23h past |
23 hours ago |
+24h past |
1 day ago |
+6d past |
6 days ago |
+7d past, same year |
date form (e.g. now=2026-02-23 → 16 Feb) |
+1y past, different year |
date form with year (e.g. now=2026-05-16 → 16 May 2025) |
-1s future |
in 1 second |
-59s future |
in 59 seconds |
-60s future |
in 1 minute |
-1d future |
in 1 day |
-7d future, same year |
date form |
-1y future, different year |
date form |
Plus:
- Naive input → treated as UTC; no
TypeError. - Same-year vs different-year branch.
- Year-boundary edge:
then = 2025-12-30,now = 2026-01-02→16 May 2025-style with year (because years differ).
format_time_html tests
- Returns
markupsafe.Markup. - Contains
<time datetime="..." title="...">...</time>. datetime=attribute is ISO 8601 with+00:00offset.title=matchesYYYY-MM-DD HH:MM:SS UTC.- Label inside element equals
humanize_delta(then, now).
Smoke test (Flask test client)
Render a minimal template {{ ts | timeago }} via the test client and
assert:
- Response contains
<time(filter is registered). - No literal
<time>(no double-escaping).
Verification
End-to-end checks before declaring done:
pytest l4d2web/tests -q— full suite green, including the newtest_timeago.py.- Manual UI walk in a logged-in session:
/admin/users—Created/Updatedcolumns show "N minutes ago" / date form./blueprints— same./jobs(and a single job detail) —Created/Started/Finisheduse the filter; nullFinishedstill shows-.- A server with live state —
polled N seconds ago,joined N minutes ago,last seen N minutes ago.
- Hover one of the new
<time>elements — browser-native tooltip shows the UTC string. - Check page source — no double-escaped tags;
<time>element present with both attributes.
Out of scope
- Switching
models.pyDateTime columns totimezone=Trueand removing the ~6replace(tzinfo=None)workarounds. Worth doing, but mechanical and separable. See follow-up below. - Client-side ticker JS to keep visible labels fresh on long-open tabs. The tooltip carries the precise UTC value; HTMX-polled pages refresh anyway.
- Duration formatting (
running for 12 minutes). The helper renders points in time, not intervals. - Localisation. English month names everywhere, matching the rest of the app's strings.
Follow-up spec (file separately after this lands)
docs/superpowers/specs/YYYY-MM-DD-naive-datetime-cleanup-design.md —
switch models.py DateTime columns to DateTime(timezone=True),
remove the .replace(tzinfo=None) workarounds in
profile_routes.py:88, server_routes.py:210/234/273,
page_routes.py:174, live_state_poller.py:37, and confirm auth
markers, staleness comparisons, and session helpers still work.
Mechanical refactor, no behaviour change visible to users. The
timeago helper's defensive tzinfo is None line becomes a no-op
afterwards — leave it as a safety net.