# 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.
