Compare commits

..

No commits in common. "55b2abfdc9aef812b13007357a02fd9f307bf16b" and "c3ce6d447af074723020e92fad065bef5b3fa861" have entirely different histories.

14 changed files with 46 additions and 1213 deletions

View file

@ -1,747 +0,0 @@
# 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 "&lt;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 `&lt;time&gt;`.
- [ ] **Step 5: No-op commit not required — work is already committed across Tasks 1-8.**
End of plan.

View file

@ -1,248 +0,0 @@
# 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.

View file

@ -28,7 +28,6 @@ from l4d2web.services.job_worker import (
start_state_poller, start_state_poller,
) )
from l4d2web.services.live_state_poller import start_live_state_poller from l4d2web.services.live_state_poller import start_live_state_poller
from l4d2web.services.timeago import format_time_html
def _in_flask_cli_context() -> bool: def _in_flask_cli_context() -> bool:
@ -59,8 +58,6 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
with app.app_context(): with app.app_context():
init_db() init_db()
app.add_template_filter(format_time_html, "timeago")
@app.before_request @app.before_request
def csrf_protect() -> Response | None: def csrf_protect() -> Response | None:
if "csrf_token" not in session: if "csrf_token" not in session:

View file

@ -237,6 +237,8 @@ _TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
def _build_server_actions_context(db, server) -> dict: def _build_server_actions_context(db, server) -> dict:
from l4d2web.services.timeago import humanize_delta
latest_job = db.scalar( latest_job = db.scalar(
select(Job) select(Job)
.where(Job.server_id == server.id) .where(Job.server_id == server.id)
@ -279,7 +281,7 @@ def _build_server_actions_context(db, server) -> dict:
drift = (not has_active_job) and desired_state != actual_state drift = (not has_active_job) and desired_state != actual_state
latest_job_phrase: str | None = None latest_job_phrase: str | None = None
latest_job_at: datetime | None = None latest_job_when: str | None = None
latest_job_is_running = False latest_job_is_running = False
if latest_job is not None: if latest_job is not None:
if latest_job.state in _TERMINAL_JOB_STATES: if latest_job.state in _TERMINAL_JOB_STATES:
@ -289,7 +291,7 @@ def _build_server_actions_context(db, server) -> dict:
latest_job_phrase = _OPERATION_GERUND.get(latest_job.operation, latest_job.operation) latest_job_phrase = _OPERATION_GERUND.get(latest_job.operation, latest_job.operation)
latest_job_is_running = True latest_job_is_running = True
ref_time = latest_job.started_at or latest_job.created_at ref_time = latest_job.started_at or latest_job.created_at
latest_job_at = ref_time latest_job_when = humanize_delta(ref_time)
return { return {
"display_state": display_state, "display_state": display_state,
@ -298,7 +300,7 @@ def _build_server_actions_context(db, server) -> dict:
"drift": drift, "drift": drift,
"latest_job": latest_job, "latest_job": latest_job,
"latest_job_phrase": latest_job_phrase, "latest_job_phrase": latest_job_phrase,
"latest_job_at": latest_job_at, "latest_job_when": latest_job_when,
"latest_job_is_running": latest_job_is_running, "latest_job_is_running": latest_job_is_running,
} }
@ -438,6 +440,8 @@ _BUILD_STATE_LABELS = {
def _build_overlay_build_status_context(db, overlay) -> dict: def _build_overlay_build_status_context(db, overlay) -> dict:
from l4d2web.services.timeago import humanize_delta
latest_build = db.scalar( latest_build = db.scalar(
select(Job) select(Job)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id) .where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
@ -460,7 +464,7 @@ def _build_overlay_build_status_context(db, overlay) -> dict:
) )
latest_build_phrase: str | None = None latest_build_phrase: str | None = None
latest_build_at: datetime | None = None latest_build_when: str | None = None
if latest_build is not None: if latest_build is not None:
if latest_build.state in _TERMINAL_JOB_STATES: if latest_build.state in _TERMINAL_JOB_STATES:
latest_build_phrase = f"{latest_build.operation} {latest_build.state}" latest_build_phrase = f"{latest_build.operation} {latest_build.state}"
@ -468,13 +472,13 @@ def _build_overlay_build_status_context(db, overlay) -> dict:
else: else:
latest_build_phrase = "building" latest_build_phrase = "building"
ref_time = latest_build.started_at or latest_build.created_at ref_time = latest_build.started_at or latest_build.created_at
latest_build_at = ref_time latest_build_when = humanize_delta(ref_time)
return { return {
"latest_build": latest_build, "latest_build": latest_build,
"latest_build_is_running": is_running, "latest_build_is_running": is_running,
"latest_build_phrase": latest_build_phrase, "latest_build_phrase": latest_build_phrase,
"latest_build_at": latest_build_at, "latest_build_when": latest_build_when,
"build_state_label": build_state_label, "build_state_label": build_state_label,
"build_state_class": build_state_class, "build_state_class": build_state_class,
} }

View file

@ -270,5 +270,6 @@ def live_state_fragment(server_id: int) -> Response:
snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff), snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff),
current_players=current_rows, current_players=current_rows,
recent_players=recent_rows, recent_players=recent_rows,
now=datetime.now(UTC).replace(tzinfo=None),
poll_seconds=max(1, int(current_app.config.get("LIVE_STATE_POLL_SECONDS", 5))), poll_seconds=max(1, int(current_app.config.get("LIVE_STATE_POLL_SECONDS", 5))),
) )

View file

@ -1,70 +1,29 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from markupsafe import Markup, escape
_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: def humanize_delta(then: datetime, now: datetime | None = None) -> str:
if now is None: if now is None:
now = datetime.now(UTC) now = datetime.now(UTC)
then = _ensure_utc(then) if then.tzinfo is None:
now = _ensure_utc(now) then = then.replace(tzinfo=UTC)
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
delta_seconds = int((now - then).total_seconds()) seconds = int((now - then).total_seconds())
abs_seconds = abs(delta_seconds) if seconds < 0:
seconds = 0
if abs_seconds == 0: if seconds < 45:
return "now" return "just now"
if seconds < 90:
if abs_seconds >= 7 * 86400: return "1 minute ago"
return _format_date(then, now) minutes = seconds // 60
if minutes < 60:
return _relative_label(abs_seconds, past=(delta_seconds > 0)) return f"{minutes} minutes ago"
hours = minutes // 60
if hours < 24:
def format_time_html(then: datetime, now: datetime | None = None) -> Markup: return "1 hour ago" if hours == 1 else f"{hours} hours ago"
if now is None: days = hours // 24
now = datetime.now(UTC) if days < 7:
then_utc = _ensure_utc(then).astimezone(UTC) return "1 day ago" if days == 1 else f"{days} days ago"
now = _ensure_utc(now) return then.date().isoformat()
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>"
)

View file

@ -19,8 +19,8 @@
<td>{{ job.state }}</td> <td>{{ job.state }}</td>
{% if show_user %}<td>{{ user.username if user else "system" }}</td>{% endif %} {% if show_user %}<td>{{ user.username if user else "system" }}</td>{% endif %}
{% if show_server %}<td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td>{% endif %} {% if show_server %}<td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td>{% endif %}
<td>{{ job.created_at | timeago }}</td> <td>{{ job.created_at }}</td>
<td>{% if job.finished_at %}{{ job.finished_at | timeago }}{% else %}-{% endif %}</td> <td>{{ job.finished_at or "-" }}</td>
{% if show_cancel %} {% if show_cancel %}
<td> <td>
{% if job.state in ["queued", "running"] %} {% if job.state in ["queued", "running"] %}

View file

@ -7,7 +7,7 @@
{% if snapshot.hibernating %}· idle{% endif %} {% if snapshot.hibernating %}· idle{% endif %}
· {{ snapshot.map }} · {{ snapshot.map }}
<small class="muted"> <small class="muted">
polled {{ snapshot.last_seen_at | timeago }} polled {{ ((now - snapshot.last_seen_at).total_seconds() | int) }}s ago
</small> </small>
</p> </p>
{% endif %} {% endif %}
@ -28,7 +28,7 @@
<span class="name">{{ (profile and profile.persona_name) or session.name_at_join }}</span> <span class="name">{{ (profile and profile.persona_name) or session.name_at_join }}</span>
</a> </a>
<span class="meta"> <span class="meta">
joined {{ session.joined_at | timeago }} joined {{ ((now - session.joined_at).total_seconds() // 60) | int }}m ago
· ping {{ session.min_ping }}-{{ session.max_ping }}ms · ping {{ session.min_ping }}-{{ session.max_ping }}ms
</span> </span>
</li> </li>
@ -52,7 +52,7 @@
<span class="name">{{ row.persona_name or row.name_at_join }}</span> <span class="name">{{ row.persona_name or row.name_at_join }}</span>
</a> </a>
<span class="meta"> <span class="meta">
last seen {{ row.last_seen | timeago }} last seen {{ ((now - row.last_seen).total_seconds() // 60) | int }}m ago
</span> </span>
</li> </li>
{% endfor %} {% endfor %}

View file

@ -8,7 +8,7 @@
<p class="last-job"> <p class="last-job">
<a href="/jobs/{{ latest_build.id }}">{{ latest_build_phrase }}</a> <a href="/jobs/{{ latest_build.id }}">{{ latest_build_phrase }}</a>
{% if latest_build_is_running %}since{% endif %} {% if latest_build_is_running %}since{% endif %}
{{ latest_build_at | timeago }} {{ latest_build_when }}
(<a href="/overlays/{{ overlay.id }}/jobs">show all</a>) (<a href="/overlays/{{ overlay.id }}/jobs">show all</a>)
</p> </p>
{% endif %} {% endif %}

View file

@ -26,7 +26,7 @@
<p class="last-job"> <p class="last-job">
<a href="/jobs/{{ latest_job.id }}">{{ latest_job_phrase }}</a> <a href="/jobs/{{ latest_job.id }}">{{ latest_job_phrase }}</a>
{% if latest_job_is_running %}since{% endif %} {% if latest_job_is_running %}since{% endif %}
{{ latest_job_at | timeago }} {{ latest_job_when }}
(<a href="/servers/{{ server.id }}/jobs">show all</a>) (<a href="/servers/{{ server.id }}/jobs">show all</a>)
</p> </p>
{% endif %} {% endif %}

View file

@ -22,8 +22,8 @@
<td>{{ user.username }}</td> <td>{{ user.username }}</td>
<td>{{ "yes" if user.admin else "no" }}</td> <td>{{ "yes" if user.admin else "no" }}</td>
<td>{{ "yes" if user.active else "no" }}</td> <td>{{ "yes" if user.active else "no" }}</td>
<td>{{ user.created_at | timeago }}</td> <td>{{ user.created_at }}</td>
<td>{{ user.updated_at | timeago }}</td> <td>{{ user.updated_at }}</td>
<td> <td>
{% if user.id == g.user.id %} {% if user.id == g.user.id %}
<span class="muted">you</span> <span class="muted">you</span>

View file

@ -14,8 +14,8 @@
{% for blueprint in blueprints %} {% for blueprint in blueprints %}
<tr> <tr>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td> <td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ blueprint.created_at | timeago }}</td> <td>{{ blueprint.created_at }}</td>
<td>{{ blueprint.updated_at | timeago }}</td> <td>{{ blueprint.updated_at }}</td>
<td><a href="/servers?blueprint_id={{ blueprint.id }}">Create server</a></td> <td><a href="/servers?blueprint_id={{ blueprint.id }}">Create server</a></td>
</tr> </tr>
{% else %} {% else %}

View file

@ -21,9 +21,9 @@
<tr><th>State</th><td>{{ job.state }}</td></tr> <tr><th>State</th><td>{{ job.state }}</td></tr>
<tr><th>User</th><td>{{ owner.username if owner else "system" }}</td></tr> <tr><th>User</th><td>{{ owner.username if owner else "system" }}</td></tr>
<tr><th>Server</th><td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td></tr> <tr><th>Server</th><td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td></tr>
<tr><th>Created</th><td>{{ job.created_at | timeago }}</td></tr> <tr><th>Created</th><td>{{ job.created_at }}</td></tr>
<tr><th>Started</th><td>{% if job.started_at %}{{ job.started_at | timeago }}{% else %}-{% endif %}</td></tr> <tr><th>Started</th><td>{{ job.started_at or "-" }}</td></tr>
<tr><th>Finished</th><td>{% if job.finished_at %}{{ job.finished_at | timeago }}{% else %}-{% endif %}</td></tr> <tr><th>Finished</th><td>{{ job.finished_at or "-" }}</td></tr>
<tr><th>Exit code</th><td>{{ job.exit_code if job.exit_code is not none else "-" }}</td></tr> <tr><th>Exit code</th><td>{{ job.exit_code if job.exit_code is not none else "-" }}</td></tr>
</tbody> </tbody>
</table> </table>

View file

@ -1,133 +0,0 @@
from datetime import UTC, datetime, timedelta
import pytest
from markupsafe import Markup
from l4d2web.services.timeago import format_time_html, humanize_delta
NOW = datetime(2026, 5, 16, 12, 0, 0, tzinfo=UTC)
@pytest.mark.parametrize(
("delta", "expected"),
[
(timedelta(0), "now"),
(timedelta(seconds=1), "1 second ago"),
(timedelta(seconds=2), "2 seconds ago"),
(timedelta(seconds=59), "59 seconds ago"),
(timedelta(seconds=60), "1 minute ago"),
(timedelta(minutes=1), "1 minute ago"),
(timedelta(minutes=2), "2 minutes ago"),
(timedelta(minutes=59), "59 minutes ago"),
(timedelta(minutes=60), "1 hour ago"),
(timedelta(hours=1), "1 hour ago"),
(timedelta(hours=2), "2 hours ago"),
(timedelta(hours=23), "23 hours ago"),
(timedelta(hours=24), "1 day ago"),
(timedelta(days=1), "1 day ago"),
(timedelta(days=2), "2 days ago"),
(timedelta(days=6), "6 days ago"),
(timedelta(days=7), "9 May"),
(timedelta(days=30), "16 Apr"),
(timedelta(days=120), "16 Jan"),
(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"),
[
(timedelta(seconds=1), "in 1 second"),
(timedelta(seconds=2), "in 2 seconds"),
(timedelta(seconds=59), "in 59 seconds"),
(timedelta(seconds=60), "in 1 minute"),
(timedelta(minutes=2), "in 2 minutes"),
(timedelta(minutes=59), "in 59 minutes"),
(timedelta(hours=1), "in 1 hour"),
(timedelta(hours=23), "in 23 hours"),
(timedelta(days=1), "in 1 day"),
(timedelta(days=6), "in 6 days"),
(timedelta(days=7), "23 May"),
(timedelta(days=30), "15 Jun"),
(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, 15, 12, 0, 0, tzinfo=UTC)
then = datetime(2025, 11, 15, 12, 0, 0, tzinfo=UTC)
assert humanize_delta(then, now=now) == "15 Nov 2025"
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
def test_timeago_filter_registered_on_app():
from flask import render_template_string
from l4d2web.app import create_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 "&lt;time" not in rendered
assert "3 minutes ago" in rendered