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:
parent
c3ce6d447a
commit
f3cd981957
1 changed files with 248 additions and 0 deletions
|
|
@ -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 `<time>` (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.
|
||||
Loading…
Reference in a new issue