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>
This commit is contained in:
mwiegand 2026-05-16 10:59:15 +02:00
parent c3ce6d447a
commit f3cd981957
No known key found for this signature in database

View file

@ -0,0 +1,248 @@
# 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:
```html
<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:
```jinja
{% 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-23``now_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-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: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/users``Created` / `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.