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>
248 lines
9.7 KiB
Markdown
248 lines
9.7 KiB
Markdown
# 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.
|