left4me/docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md
mwiegand f3cd981957
spec(timeago-shared-display): one Jinja filter for all user-facing datetimes
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>
2026-05-16 10:59:15 +02:00

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.md and commit (per AGENTS.md planning-artifact convention).

Context

left4me's web UI renders datetimes inconsistently. Three styles coexist:

  1. Raw Python datetime repr in admin/blueprint/job tables (admin_users.html, blueprints.html, _job_table.html, job_detail.html) — unfriendly for users.
  2. Bespoke inline math in templates/_live_state.html — three expressions of the form ((now - x).total_seconds() // 60) | int }}m ago with their own vocabulary (12s ago, 0m ago inside the first minute, no singular handling).
  3. Centralised but Python-side humanize_delta (in l4d2web/l4d2web/services/timeago.py) called from page_routes.py for latest_job_when and latest_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.html cares 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, not Feb 16).
  • Year suppressed only when then.year == now.year.
  • then.day rendered without leading zero (use then.day directly, 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).
  • now defaults to datetime.now(UTC) and is similarly normalised.
  • None is 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 — wraps humanize_delta in the <time> element. UTC-normalises then for the datetime= and title= attributes. Escapes the title via markupsafe.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 — rewrite humanize_delta to the symmetric ladder with sub-minute precision and future support; add format_time_html.
  • Flask app factory (locate during implementation; likely l4d2web/l4d2web/app.py or __init__.py) — register format_time_html as filter timeago: app.add_template_filter(format_time_html, "timeago").

Templates (replace raw datetimes / bespoke math)

  • l4d2web/l4d2web/templates/admin_users.html:25-26
  • l4d2web/l4d2web/templates/blueprints.html:17-18
  • l4d2web/l4d2web/templates/_job_table.html:22-23
  • l4d2web/l4d2web/templates/job_detail.html:24-26
  • l4d2web/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 inline humanize_delta imports and latest_job_when / latest_build_when precomputation. Pass the raw datetime into the template context (rename keys to latest_job_at / latest_build_at for 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-23now_utc() factory.
  • l4d2web/l4d2web/routes/profile_routes.py:86-88 — documented workaround comment that explains why naive datetimes exist (informs the defensive tzinfo is None line).
  • 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-2316 Feb)
+1y past, different year date form with year (e.g. now=2026-05-1616 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-0216 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:00 offset.
  • title= matches YYYY-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 &lt;time&gt; (no double-escaping).

Verification

End-to-end checks before declaring done:

  1. pytest l4d2web/tests -q — full suite green, including the new test_timeago.py.
  2. Manual UI walk in a logged-in session:
    • /admin/usersCreated / Updated columns show "N minutes ago" / date form.
    • /blueprints — same.
    • /jobs (and a single job detail) — Created / Started / Finished use the filter; null Finished still shows -.
    • A server with live state — polled N seconds ago, joined N minutes ago, last seen N minutes ago.
  3. Hover one of the new <time> elements — browser-native tooltip shows the UTC string.
  4. Check page source — no double-escaped tags; <time> element present with both attributes.

Out of scope

  • Switching models.py DateTime columns to timezone=True and removing the ~6 replace(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.