plan(timeago-shared-display): nine-task TDD migration to a Jinja filter
Lays out the file-by-file migration from the current three time-display styles to the unified timeago filter from the design spec. TDD ordering with tests-first, per-task commits, line-numbered locators, and an explicit verification pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f3cd981957
commit
fdcefcfec6
1 changed files with 747 additions and 0 deletions
747
docs/superpowers/plans/2026-05-16-timeago-shared-display.md
Normal file
747
docs/superpowers/plans/2026-05-16-timeago-shared-display.md
Normal file
|
|
@ -0,0 +1,747 @@
|
||||||
|
# timeago Shared Display Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Unify all user-facing datetime rendering in `l4d2web` behind a single `timeago` Jinja filter that returns a `<time>` element with a relative label and a precise UTC tooltip.
|
||||||
|
|
||||||
|
**Architecture:** Two callables in `l4d2web/l4d2web/services/timeago.py` — `humanize_delta` (pure text, source of truth for the relative-label ladder) and `format_time_html` (wraps the text in a `<time>` element). The latter is registered as Jinja filter `timeago` in the Flask app factory. Templates and routes migrate from raw datetime repr and bespoke inline math to `{{ ts | timeago }}`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.13, Flask, Jinja2, `markupsafe.Markup`, pytest.
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `l4d2web/l4d2web/services/timeago.py` | Rewrite | `humanize_delta` (new symmetric ladder) + new `format_time_html` |
|
||||||
|
| `l4d2web/l4d2web/app.py` | Modify | Register `timeago` filter in `create_app` |
|
||||||
|
| `l4d2web/tests/test_timeago.py` | Create | Unit tests for both helpers + Flask smoke test |
|
||||||
|
| `l4d2web/l4d2web/templates/admin_users.html` | Modify | Use filter for `created_at` / `updated_at` |
|
||||||
|
| `l4d2web/l4d2web/templates/blueprints.html` | Modify | Use filter for `created_at` / `updated_at` |
|
||||||
|
| `l4d2web/l4d2web/templates/_job_table.html` | Modify | Use filter for `created_at` / `finished_at` (with None guard) |
|
||||||
|
| `l4d2web/l4d2web/templates/job_detail.html` | Modify | Use filter for `created_at` / `started_at` / `finished_at` |
|
||||||
|
| `l4d2web/l4d2web/templates/_live_state.html` | Modify | Replace inline `(now - x).total_seconds()` with filter |
|
||||||
|
| `l4d2web/l4d2web/templates/_server_actions.html` | Modify | Switch from `latest_job_when` (string) to `latest_job_at \| timeago` |
|
||||||
|
| `l4d2web/l4d2web/templates/_overlay_build_status.html` | Modify | Switch from `latest_build_when` to `latest_build_at \| timeago` |
|
||||||
|
| `l4d2web/l4d2web/routes/page_routes.py` | Modify | Drop `humanize_delta` imports; pass raw datetime as `latest_job_at` / `latest_build_at` |
|
||||||
|
| `l4d2web/l4d2web/routes/server_routes.py` | Modify | Remove now-dead `now=` kwarg from `_live_state.html` render call |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Rewrite `humanize_delta` with the new ladder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/services/timeago.py`
|
||||||
|
- Create: `l4d2web/tests/test_timeago.py`
|
||||||
|
|
||||||
|
The current ladder uses `just now` under 45s and clamps future deltas. The new ladder is symmetric, has second precision, and uses day-month (with year if different) for ≥7 days. Spec table in section "Ladder (long form, symmetric for past and future)".
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the test file with parameterised boundary tests**
|
||||||
|
|
||||||
|
Create `l4d2web/tests/test_timeago.py` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from l4d2web.services.timeago import humanize_delta
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 5, 16, 12, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("delta", "expected"),
|
||||||
|
[
|
||||||
|
# zero
|
||||||
|
(timedelta(0), "now"),
|
||||||
|
# past, seconds
|
||||||
|
(timedelta(seconds=1), "1 second ago"),
|
||||||
|
(timedelta(seconds=2), "2 seconds ago"),
|
||||||
|
(timedelta(seconds=59), "59 seconds ago"),
|
||||||
|
# past, minutes
|
||||||
|
(timedelta(seconds=60), "1 minute ago"),
|
||||||
|
(timedelta(minutes=1), "1 minute ago"),
|
||||||
|
(timedelta(minutes=2), "2 minutes ago"),
|
||||||
|
(timedelta(minutes=59), "59 minutes ago"),
|
||||||
|
# past, hours
|
||||||
|
(timedelta(minutes=60), "1 hour ago"),
|
||||||
|
(timedelta(hours=1), "1 hour ago"),
|
||||||
|
(timedelta(hours=2), "2 hours ago"),
|
||||||
|
(timedelta(hours=23), "23 hours ago"),
|
||||||
|
# past, days
|
||||||
|
(timedelta(hours=24), "1 day ago"),
|
||||||
|
(timedelta(days=1), "1 day ago"),
|
||||||
|
(timedelta(days=2), "2 days ago"),
|
||||||
|
(timedelta(days=6), "6 days ago"),
|
||||||
|
# past, date fallback same year (now = 16 May 2026)
|
||||||
|
(timedelta(days=7), "9 May"),
|
||||||
|
(timedelta(days=30), "16 Apr"),
|
||||||
|
(timedelta(days=120), "16 Jan"),
|
||||||
|
# past, date fallback different year
|
||||||
|
(timedelta(days=365), "16 May 2025"),
|
||||||
|
(timedelta(days=400), "11 Apr 2025"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_humanize_delta_past(delta, expected):
|
||||||
|
then = NOW - delta
|
||||||
|
assert humanize_delta(then, now=NOW) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("delta", "expected"),
|
||||||
|
[
|
||||||
|
# future, seconds
|
||||||
|
(timedelta(seconds=1), "in 1 second"),
|
||||||
|
(timedelta(seconds=2), "in 2 seconds"),
|
||||||
|
(timedelta(seconds=59), "in 59 seconds"),
|
||||||
|
# future, minutes
|
||||||
|
(timedelta(seconds=60), "in 1 minute"),
|
||||||
|
(timedelta(minutes=2), "in 2 minutes"),
|
||||||
|
(timedelta(minutes=59), "in 59 minutes"),
|
||||||
|
# future, hours
|
||||||
|
(timedelta(hours=1), "in 1 hour"),
|
||||||
|
(timedelta(hours=23), "in 23 hours"),
|
||||||
|
# future, days
|
||||||
|
(timedelta(days=1), "in 1 day"),
|
||||||
|
(timedelta(days=6), "in 6 days"),
|
||||||
|
# future, date fallback same year
|
||||||
|
(timedelta(days=7), "23 May"),
|
||||||
|
(timedelta(days=30), "15 Jun"),
|
||||||
|
# future, date fallback different year
|
||||||
|
(timedelta(days=365), "16 May 2027"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_humanize_delta_future(delta, expected):
|
||||||
|
then = NOW + delta
|
||||||
|
assert humanize_delta(then, now=NOW) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_humanize_delta_accepts_naive_input_as_utc():
|
||||||
|
then_naive = (NOW - timedelta(minutes=5)).replace(tzinfo=None)
|
||||||
|
assert humanize_delta(then_naive, now=NOW) == "5 minutes ago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_humanize_delta_accepts_naive_now_as_utc():
|
||||||
|
then = NOW - timedelta(minutes=5)
|
||||||
|
now_naive = NOW.replace(tzinfo=None)
|
||||||
|
assert humanize_delta(then, now=now_naive) == "5 minutes ago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_humanize_delta_default_now_is_datetime_now_utc():
|
||||||
|
then = datetime.now(UTC) - timedelta(seconds=3)
|
||||||
|
assert humanize_delta(then) in {"3 seconds ago", "2 seconds ago", "4 seconds ago"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_humanize_delta_year_boundary_includes_year_when_years_differ():
|
||||||
|
now = datetime(2026, 1, 2, 12, 0, 0, tzinfo=UTC)
|
||||||
|
then = datetime(2025, 12, 30, 12, 0, 0, tzinfo=UTC)
|
||||||
|
assert humanize_delta(then, now=now) == "30 Dec 2025"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new tests to verify they fail against the current implementation**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_timeago.py -v`
|
||||||
|
Expected: most past tests FAIL (current implementation returns `just now` under 45s, no singular `1 second ago`); all future tests FAIL (current clamps to 0 → `just now`); date-fallback tests FAIL (current returns ISO `2025-04-21` not `9 May`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite `humanize_delta` to satisfy the tests**
|
||||||
|
|
||||||
|
Replace the entire contents of `l4d2web/l4d2web/services/timeago.py` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
|
||||||
|
_MONTHS = (
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_utc(dt: datetime) -> datetime:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=UTC)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date(then: datetime, now: datetime) -> str:
|
||||||
|
month = _MONTHS[then.month - 1]
|
||||||
|
if then.year == now.year:
|
||||||
|
return f"{then.day} {month}"
|
||||||
|
return f"{then.day} {month} {then.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_label(seconds: int, past: bool) -> str:
|
||||||
|
if seconds < 60:
|
||||||
|
unit, n = "second", seconds
|
||||||
|
elif seconds < 3600:
|
||||||
|
unit, n = "minute", seconds // 60
|
||||||
|
elif seconds < 86400:
|
||||||
|
unit, n = "hour", seconds // 3600
|
||||||
|
else:
|
||||||
|
unit, n = "day", seconds // 86400
|
||||||
|
plural = "" if n == 1 else "s"
|
||||||
|
if past:
|
||||||
|
return f"{n} {unit}{plural} ago"
|
||||||
|
return f"in {n} {unit}{plural}"
|
||||||
|
|
||||||
|
|
||||||
|
def humanize_delta(then: datetime, now: datetime | None = None) -> str:
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
then = _ensure_utc(then)
|
||||||
|
now = _ensure_utc(now)
|
||||||
|
|
||||||
|
delta_seconds = int((now - then).total_seconds())
|
||||||
|
abs_seconds = abs(delta_seconds)
|
||||||
|
|
||||||
|
if abs_seconds == 0:
|
||||||
|
return "now"
|
||||||
|
|
||||||
|
if abs_seconds >= 7 * 86400:
|
||||||
|
return _format_date(then, now)
|
||||||
|
|
||||||
|
return _relative_label(abs_seconds, past=(delta_seconds > 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_timeago.py -v`
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full test suite to check for regressions in callers of `humanize_delta`**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests -q`
|
||||||
|
Expected: all tests pass. If any pre-existing test asserts on the legacy "just now" / 7-day ISO fallback strings via `latest_job_when` rendering, update those assertions to match the new format (e.g. "1 second ago", "9 May"). Note in commit message which tests were updated and why.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/services/timeago.py l4d2web/tests/test_timeago.py
|
||||||
|
git commit -m "feat(timeago): symmetric ladder with second precision and date fallback
|
||||||
|
|
||||||
|
Rewrite humanize_delta as a symmetric past/future ladder with
|
||||||
|
sub-minute precision. Replace the bare ISO date fallback after 7 days
|
||||||
|
with a day-month form (year suppressed when same as now). Refs spec
|
||||||
|
docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add `format_time_html` returning a `<time>` element
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/services/timeago.py`
|
||||||
|
- Modify: `l4d2web/tests/test_timeago.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append tests for `format_time_html` to the test file**
|
||||||
|
|
||||||
|
Append to `l4d2web/tests/test_timeago.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from l4d2web.services.timeago import format_time_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_returns_markup():
|
||||||
|
then = NOW - timedelta(minutes=5)
|
||||||
|
out = format_time_html(then, now=NOW)
|
||||||
|
assert isinstance(out, Markup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_contains_time_element_with_attrs():
|
||||||
|
then = datetime(2026, 5, 16, 14, 32, 11, tzinfo=UTC)
|
||||||
|
now = then + timedelta(minutes=5)
|
||||||
|
out = str(format_time_html(then, now=now))
|
||||||
|
assert out.startswith("<time ")
|
||||||
|
assert out.endswith("</time>")
|
||||||
|
assert 'datetime="2026-05-16T14:32:11+00:00"' in out
|
||||||
|
assert 'title="2026-05-16 14:32:11 UTC"' in out
|
||||||
|
assert ">5 minutes ago<" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_label_matches_humanize_delta():
|
||||||
|
then = NOW - timedelta(hours=2)
|
||||||
|
label = humanize_delta(then, now=NOW)
|
||||||
|
out = str(format_time_html(then, now=NOW))
|
||||||
|
assert f">{label}<" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_html_normalises_naive_input_to_utc():
|
||||||
|
then_naive = datetime(2026, 5, 16, 14, 32, 11)
|
||||||
|
now = datetime(2026, 5, 16, 14, 37, 11, tzinfo=UTC)
|
||||||
|
out = str(format_time_html(then_naive, now=now))
|
||||||
|
assert 'datetime="2026-05-16T14:32:11+00:00"' in out
|
||||||
|
assert 'title="2026-05-16 14:32:11 UTC"' in out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_timeago.py -v -k format_time_html`
|
||||||
|
Expected: FAIL with `ImportError: cannot import name 'format_time_html'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `format_time_html` in `timeago.py`**
|
||||||
|
|
||||||
|
Append to `l4d2web/l4d2web/services/timeago.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
|
|
||||||
|
def format_time_html(then: datetime, now: datetime | None = None) -> Markup:
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
then_utc = _ensure_utc(then).astimezone(UTC)
|
||||||
|
now = _ensure_utc(now)
|
||||||
|
|
||||||
|
label = humanize_delta(then_utc, now=now)
|
||||||
|
iso = then_utc.isoformat()
|
||||||
|
title = then_utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
return Markup(
|
||||||
|
f'<time datetime="{escape(iso)}" title="{escape(title)}">'
|
||||||
|
f"{escape(label)}</time>"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: place the `from markupsafe import Markup, escape` import at the top of the file alongside the existing `from datetime import ...` line — don't leave it inline as written above.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_timeago.py -v`
|
||||||
|
Expected: all tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/services/timeago.py l4d2web/tests/test_timeago.py
|
||||||
|
git commit -m "feat(timeago): add format_time_html returning a <time> element
|
||||||
|
|
||||||
|
Wrap humanize_delta in an HTML <time> element with datetime= and
|
||||||
|
title= attributes carrying the precise UTC value, so hovering surfaces
|
||||||
|
the exact timestamp regardless of the relative label."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Register `timeago` Jinja filter in the Flask app factory
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/app.py:37-58`
|
||||||
|
- Modify: `l4d2web/tests/test_timeago.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a Flask smoke test for the filter**
|
||||||
|
|
||||||
|
There is no shared `app` fixture in this codebase — each test instantiates `create_app` directly (see `l4d2web/tests/test_health.py` for the minimal pattern). Append to `l4d2web/tests/test_timeago.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import render_template_string
|
||||||
|
|
||||||
|
from l4d2web.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeago_filter_registered_on_app():
|
||||||
|
app = create_app({"TESTING": True, "SECRET_KEY": "test"})
|
||||||
|
with app.app_context():
|
||||||
|
rendered = render_template_string(
|
||||||
|
"{{ ts | timeago }}",
|
||||||
|
ts=datetime.now(UTC) - timedelta(minutes=3),
|
||||||
|
)
|
||||||
|
assert "<time " in rendered
|
||||||
|
assert "<time" not in rendered
|
||||||
|
assert "3 minutes ago" in rendered
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the fixture and the failing assertion**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_timeago.py::test_timeago_filter_registered_on_app -v`
|
||||||
|
Expected: FAIL with a Jinja `TemplateSyntaxError: No filter named 'timeago'` (or similar), confirming the filter is not yet registered.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register the filter in `create_app`**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/app.py`:
|
||||||
|
|
||||||
|
Add the import near the other `from l4d2web...` imports at the top:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from l4d2web.services.timeago import format_time_html
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside `create_app`, register the filter immediately after `init_db()` runs and before the `@app.before_request` definitions. Add a single line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
app.add_template_filter(format_time_html, "timeago")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the smoke test to verify it passes**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_timeago.py::test_timeago_filter_registered_on_app -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full test suite to confirm nothing else broke**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests -q`
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/app.py l4d2web/tests/test_timeago.py
|
||||||
|
git commit -m "feat(app): register timeago Jinja filter
|
||||||
|
|
||||||
|
Templates can now call {{ ts | timeago }} directly without route-side
|
||||||
|
precomputation."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Migrate `admin_users.html` and `blueprints.html`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/admin_users.html:25-26`
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/blueprints.html:17-18`
|
||||||
|
|
||||||
|
Both templates render `created_at` / `updated_at` as raw Python `datetime` repr. No `None` guard needed — these columns are `nullable=False` in `models.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Modify `admin_users.html`**
|
||||||
|
|
||||||
|
Replace lines 25-26 of `l4d2web/l4d2web/templates/admin_users.html`:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<td>{{ user.created_at }}</td>
|
||||||
|
<td>{{ user.updated_at }}</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<td>{{ user.created_at | timeago }}</td>
|
||||||
|
<td>{{ user.updated_at | timeago }}</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modify `blueprints.html`**
|
||||||
|
|
||||||
|
Replace lines 17-18 of `l4d2web/l4d2web/templates/blueprints.html`:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<td>{{ blueprint.created_at }}</td>
|
||||||
|
<td>{{ blueprint.updated_at }}</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<td>{{ blueprint.created_at | timeago }}</td>
|
||||||
|
<td>{{ blueprint.updated_at | timeago }}</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the existing tests for these pages**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_admin_users.py l4d2web/tests/test_blueprints.py -q`
|
||||||
|
Expected: all tests pass. If a test asserts on the raw datetime string in the rendered HTML, update it to assert the presence of `<time ` for the same row instead.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/templates/admin_users.html l4d2web/l4d2web/templates/blueprints.html
|
||||||
|
git commit -m "refactor(templates): use timeago filter for admin/blueprint timestamps"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Migrate `_job_table.html` and `job_detail.html` (with `None` guards)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/_job_table.html:22-23`
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/job_detail.html:24-26`
|
||||||
|
|
||||||
|
In `models.py`, `Job.started_at` and `Job.finished_at` are nullable; `Job.created_at` is not. Preserve the existing `-` placeholder for nullable columns.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Modify `_job_table.html`**
|
||||||
|
|
||||||
|
Replace lines 22-23 of `l4d2web/l4d2web/templates/_job_table.html`:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<td>{{ job.created_at }}</td>
|
||||||
|
<td>{{ job.finished_at or "-" }}</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<td>{{ job.created_at | timeago }}</td>
|
||||||
|
<td>{% if job.finished_at %}{{ job.finished_at | timeago }}{% else %}-{% endif %}</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modify `job_detail.html`**
|
||||||
|
|
||||||
|
Replace lines 24-26 of `l4d2web/l4d2web/templates/job_detail.html`:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<tr><th>Created</th><td>{{ job.created_at }}</td></tr>
|
||||||
|
<tr><th>Started</th><td>{{ job.started_at or "-" }}</td></tr>
|
||||||
|
<tr><th>Finished</th><td>{{ job.finished_at or "-" }}</td></tr>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<tr><th>Created</th><td>{{ job.created_at | timeago }}</td></tr>
|
||||||
|
<tr><th>Started</th><td>{% if job.started_at %}{{ job.started_at | timeago }}{% else %}-{% endif %}</td></tr>
|
||||||
|
<tr><th>Finished</th><td>{% if job.finished_at %}{{ job.finished_at | timeago }}{% else %}-{% endif %}</td></tr>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the job-related tests**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_job_logs.py l4d2web/tests/test_pages.py -q`
|
||||||
|
Expected: all tests pass. Update assertions that pin raw-datetime substrings to instead assert `<time `; the `-` placeholder for nullable fields must still render in the absence of `started_at` / `finished_at`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/templates/_job_table.html l4d2web/l4d2web/templates/job_detail.html
|
||||||
|
git commit -m "refactor(templates): use timeago filter for job timestamps
|
||||||
|
|
||||||
|
Preserves the existing '-' placeholder for nullable started_at /
|
||||||
|
finished_at columns."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Migrate `_live_state.html`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/_live_state.html:9-11, 30-33, 53-56`
|
||||||
|
|
||||||
|
Three call sites; all use bespoke `(now - x).total_seconds() // …` math. Replace with the filter. The `now` template variable becomes unused inside this file after the rewrite.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the `polled Ns ago` line (lines 9-11)**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/templates/_live_state.html`, find:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<small class="muted">
|
||||||
|
polled {{ ((now - snapshot.last_seen_at).total_seconds() | int) }}s ago
|
||||||
|
</small>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<small class="muted">
|
||||||
|
polled {{ snapshot.last_seen_at | timeago }}
|
||||||
|
</small>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the `joined Nm ago` line (line 31)**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<span class="meta">
|
||||||
|
joined {{ ((now - session.joined_at).total_seconds() // 60) | int }}m ago
|
||||||
|
· ping {{ session.min_ping }}-{{ session.max_ping }}ms
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<span class="meta">
|
||||||
|
joined {{ session.joined_at | timeago }}
|
||||||
|
· ping {{ session.min_ping }}-{{ session.max_ping }}ms
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the `last seen Nm ago` line (line 55)**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<span class="meta">
|
||||||
|
last seen {{ ((now - row.last_seen).total_seconds() // 60) | int }}m ago
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
<span class="meta">
|
||||||
|
last seen {{ row.last_seen | timeago }}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the live-state tests**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests/test_servers.py -q`
|
||||||
|
Expected: tests pass. The two tests `test_servers_index_renders_live_state_badge` and `test_live_state_fragment_renders_current_and_recent` (server_routes.py:449, 513) render this fragment. If they assert on `Nm ago` substrings, replace those assertions with checks for `<time ` or for the new long-form output (e.g. `joined 5 minutes ago`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/templates/_live_state.html
|
||||||
|
git commit -m "refactor(templates): use timeago filter in _live_state.html
|
||||||
|
|
||||||
|
Replaces three bespoke (now - x).total_seconds() expressions with the
|
||||||
|
shared filter, unifying vocabulary (no more '0m ago' inside the first
|
||||||
|
minute) and adding the UTC tooltip."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Migrate `_server_actions.html` + `_overlay_build_status.html` + `page_routes.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/routes/page_routes.py:240-305, 442-484`
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/_server_actions.html:25-32`
|
||||||
|
- Modify: `l4d2web/l4d2web/templates/_overlay_build_status.html:7-14`
|
||||||
|
|
||||||
|
The two route helpers currently precompute a string via `humanize_delta`. Replace with raw `datetime` passed under a new key, let the template apply the filter.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `_build_server_actions_context` in `page_routes.py`**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/routes/page_routes.py`, replace the block at lines 239-305 (function body of `_build_server_actions_context`) so that:
|
||||||
|
|
||||||
|
- Line 240 — remove `from l4d2web.services.timeago import humanize_delta`.
|
||||||
|
- Line 284 — rename `latest_job_when: str | None = None` to `latest_job_at: datetime | None = None`.
|
||||||
|
- Line 294 — replace `latest_job_when = humanize_delta(ref_time)` with `latest_job_at = ref_time`.
|
||||||
|
- Line 303 — update the returned dict key from `"latest_job_when": latest_job_when` to `"latest_job_at": latest_job_at`.
|
||||||
|
|
||||||
|
`datetime` is already imported at `page_routes.py:2` (`from datetime import UTC, datetime, timedelta`) — no import change needed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `_build_overlay_build_status_context` in `page_routes.py`**
|
||||||
|
|
||||||
|
In the same file, replace the block at lines 442-484 (function body of `_build_overlay_build_status_context`) so that:
|
||||||
|
|
||||||
|
- Line 443 — remove `from l4d2web.services.timeago import humanize_delta`.
|
||||||
|
- Line 467 — rename `latest_build_when: str | None = None` to `latest_build_at: datetime | None = None`.
|
||||||
|
- Line 475 — replace `latest_build_when = humanize_delta(ref_time)` with `latest_build_at = ref_time`.
|
||||||
|
- Line 481 — update the returned dict key from `"latest_build_when": latest_build_when` to `"latest_build_at": latest_build_at`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `_server_actions.html`**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/templates/_server_actions.html`, line 29, replace:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{{ latest_job_when }}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{{ latest_job_at | timeago }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `_overlay_build_status.html`**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/templates/_overlay_build_status.html`, line 11, replace:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{{ latest_build_when }}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{{ latest_build_at | timeago }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the test suite to catch context-key mismatches**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests -q`
|
||||||
|
Expected: tests pass. The most likely failure point is tests that check the rendered server actions fragment (`test_servers.py`) or overlay build status fragment. If any test asserts the old `latest_job_when` string output, update it to look for `<time ` or the new long-form output (e.g. `12 minutes ago`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/routes/page_routes.py l4d2web/l4d2web/templates/_server_actions.html l4d2web/l4d2web/templates/_overlay_build_status.html
|
||||||
|
git commit -m "refactor(page_routes): pass datetime to templates for timeago filter
|
||||||
|
|
||||||
|
Drop the inline humanize_delta imports and string-precomputation; pass
|
||||||
|
the raw datetime as latest_job_at / latest_build_at and let the
|
||||||
|
template apply the timeago filter. One fewer code path computing
|
||||||
|
relative-time strings."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Drop the dead `now=` kwarg from `_live_state.html` render call
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `l4d2web/l4d2web/routes/server_routes.py:266-275`
|
||||||
|
|
||||||
|
After Task 6, `_live_state.html` no longer reads `now`. Remove the kwarg from the only `render_template` call that passes it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm no other template uses the `now` context variable**
|
||||||
|
|
||||||
|
Run: `grep -rn "\\bnow\\b" l4d2web/l4d2web/templates/`
|
||||||
|
Inspect the output. The only references should be in template *files* that we have already migrated. Expected: no remaining `(now - …)` or bare `{{ now }}` references in any template.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the `now=` kwarg**
|
||||||
|
|
||||||
|
In `l4d2web/l4d2web/routes/server_routes.py`, at line 273 inside the `render_template("_live_state.html", …)` call, remove the line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
now=datetime.now(UTC).replace(tzinfo=None),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check whether `datetime` and `UTC` are still used in the file**
|
||||||
|
|
||||||
|
If lines 210 and 234 still reference `datetime.now(UTC).replace(tzinfo=None)` (for the `cutoff` and `recent_cutoff` variables), the imports stay. Don't remove them speculatively.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test suite**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests -q`
|
||||||
|
Expected: all tests pass. If a test passes a fake `now` into the live-state context expecting it to be respected, that test relied on dead code and should be updated to assert against `<time ` output relative to a real `datetime.now(UTC)` reference.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add l4d2web/l4d2web/routes/server_routes.py
|
||||||
|
git commit -m "refactor(server_routes): drop unused 'now' kwarg from _live_state render
|
||||||
|
|
||||||
|
After the timeago migration, the live-state template no longer reads
|
||||||
|
'now' — it computes relative labels through the filter, which derives
|
||||||
|
its own reference time."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: End-to-end verification
|
||||||
|
|
||||||
|
**Files:** none — verification only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the entire test suite**
|
||||||
|
|
||||||
|
Run: `pytest l4d2web/tests -q`
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run ruff if it's part of the project's check workflow**
|
||||||
|
|
||||||
|
Run: `ruff check l4d2web/`
|
||||||
|
Expected: no new violations. The `.ruff_cache/` directory at the project root suggests ruff is in active use.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm no remaining raw-datetime renders or bespoke inline-time math**
|
||||||
|
|
||||||
|
Run: `grep -rn -E "\\{\\{ [a-z_.]+\\.(created_at|updated_at|started_at|finished_at|joined_at|last_seen|last_seen_at)" l4d2web/l4d2web/templates/`
|
||||||
|
Expected: every match is followed by `| timeago` or `| timeago }}{% else %}…{% endif %}`. No bare `{{ x.created_at }}` should remain.
|
||||||
|
|
||||||
|
Run: `grep -rn "(now -" l4d2web/l4d2web/templates/`
|
||||||
|
Expected: no matches.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual UI smoke (developer-side, optional but recommended)**
|
||||||
|
|
||||||
|
Start the dev server (see `README.md` for the exact command) and log in:
|
||||||
|
|
||||||
|
- Visit `/admin/users` — `Created` / `Updated` columns render `<time>` elements; hovering shows UTC.
|
||||||
|
- Visit `/blueprints` — same.
|
||||||
|
- Visit `/jobs` and a single job detail — `Created` / `Started` / `Finished` use the filter; null `Finished` shows `-`.
|
||||||
|
- Open a server with live state — `polled N seconds ago`, `joined N minutes ago`, `last seen N minutes ago`; check that page-source shows `<time` markup, not literal `<time>`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: No-op commit not required — work is already committed across Tasks 1-8.**
|
||||||
|
|
||||||
|
End of plan.
|
||||||
Loading…
Reference in a new issue