diff --git a/app/web/routes.py b/app/web/routes.py index 3b34b8b..38dfaf9 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -1,3 +1,4 @@ +import hashlib from datetime import timedelta from pathlib import Path @@ -13,11 +14,27 @@ from app.security.credentials import CredentialCipher from app.web.forms import UserFormData DASHBOARD_SUMMARY_WINDOW = timedelta(days=7) +CACHE_BUSTED_STATIC_FILES = ("style.css", "app.js", "htmx.min.js") router = APIRouter() templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) +def _compute_static_version(static_dir: Path) -> str: + hasher = hashlib.sha256() + for name in CACHE_BUSTED_STATIC_FILES: + try: + hasher.update((static_dir / name).read_bytes()) + except FileNotFoundError: + continue + return hasher.hexdigest()[:10] + + +templates.env.globals["static_version"] = _compute_static_version( + Path(__file__).resolve().parent / "static" +) + + def _next_sync_tick(request: Request): scheduler = getattr(request.app.state, "scheduler", None) return getattr(scheduler, "next_tick", None) diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 2f77c18..5dae5ea 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -8,9 +8,9 @@ - - - + + +
diff --git a/docs/superpowers/specs/2026-08-16-live-sync-updates-design.md b/docs/superpowers/specs/2026-08-16-live-sync-updates-design.md new file mode 100644 index 0000000..1c35739 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-live-sync-updates-design.md @@ -0,0 +1,187 @@ +# Live Sync Updates (HTMX) — Design + +Date: 2026-08-16 +Status: Draft for user review + +## 1. Goal + +Replace the full-page navigation that currently happens after clicking +"Sync now" / "Sync all now" with an in-place update: the affected rider +row(s) refresh with their new status, and a short toast reports the +outcome — without leaving the dashboard or account page. + +## 2. Scope + +### In scope + +- Vendoring htmx (v2.0.10, self-hosted, no CDN) as the swap mechanism. +- Dashboard: per-rider "Sync now" and "Sync all now". +- Account page (self-service): "Sync now". +- A toast notification system (one at a time, auto-dismissing) built on + htmx out-of-band swaps. +- Removing the HTTP 409 special case for "sync already running" — it + becomes a normal toast instead of a distinct error page/status. + +### Out of scope (unchanged in this pass) + +- Activity retry button (`/activities/{id}/retry`) — still navigates to + the old `fragments/sync_result.html` page. +- Garmin MFA form (`/users/{id}/garmin-mfa`) — still navigates to the old + page; MFA failure often needs a fresh code anyway, so the extra step is + less costly there. +- Live-updating the "Recent sync runs" table on the account/user detail + pages — a completed sync's new row only appears after the next full + page load. +- Any change to `SyncManager`, `SyncOutcome`, or scheduler behavior. + +## 3. Architecture + +### htmx + +`app/web/static/htmx.min.js` (vendored, v2.0.10) is loaded in `base.html` +via ``, alongside the +existing `app.js`. + +### The "always return current state" rule + +Every htmx-driven POST route in scope returns two things in one response +body: + +1. The current, freshly-reloaded state of its own primary swap target + (even on a no-op path like "sync already running", or on the + `/sync-all` form itself, which always re-renders unchanged). This + makes every swap safe/idempotent — the target is never replaced with + nothing. +2. Exactly one out-of-band toast fragment (`fragments/toast.html`, + `hx-swap-oob="true"` on `#toast-container`) describing what happened. + +`/sync-all` additionally emits one out-of-band row update +(`fragments/user_row.html` rendered with `oob=True`) per rider whose +outcome carries a known `user_id` — riders unaffected by that run (e.g. +disabled) are left alone. + +### Shared row partial + +`app/web/templates/fragments/user_row.html` renders one +`
  • ...
  • `, taking +`row` (a `UserDashboardRow`), `csrf_token`, and `oob` (default `False`, +adds `hx-swap-oob="true"` to the root element when `True`). `dashboard.html` +`{% include %}`s it once per row in its existing loop (`oob` omitted, +defaults to `False`) instead of inlining the `
  • ` markup — this is the +only change to the existing loop, so the initial page render is +byte-for-byte equivalent to today's markup plus the new `hx-*` attributes +on the row and its form. + +### Shared account status partial + +`app/web/templates/fragments/account_status.html` renders the +`
    ...
    ` block (Status, +MyWhoosh state, Garmin state, Action reason) that today lives inline in +`account/detail.html`. Same include pattern. + +### New repository method + +`UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None` +in `app/db/repositories.py` — the existing `dashboard_rows()` loop body is +extracted into a private `_build_dashboard_row(user: SyncUser) -> UserDashboardRow` +helper that both `dashboard_rows()` and the new `dashboard_row(user_id)` +call, so there is exactly one place that assembles a row. + +### Route changes + +`app/web/operations.py`: + +- `manual_sync` (`POST /users/{user_id}/sync`): on success, on + `SyncAlreadyRunning`, and on any other outcome, always ends by opening a + fresh session, calling `UserRepository(session).dashboard_row(user_id)`, + and rendering `fragments/user_row.html` (`oob=False`, since this row IS + the primary `hx-target`) followed by a toast whose message/level depend + on the outcome. Always returns HTTP 200 now (no more 409). +- `manual_sync_all` (`POST /sync-all`): re-renders the trigering `
    ` + unchanged as the primary swap content (`fragments/sync_all_form.html`, + a two-line partial holding just that form), then one + `fragments/user_row.html` (`oob=True`) per outcome with a resolvable + `user_id`, then one summary toast, e.g. `"Synced 3 riders — 2 ok, 1 + failed"` or `"No riders to sync"` when the outcome list is empty. + +`app/web/account.py`: + +- `account_sync` (`POST /account/sync`): same "always return current + state + toast" shape, but the primary target is + `fragments/account_status.html` re-rendered from the freshly reloaded + `SyncUser`, not a row. + +### Toast levels and copy + +| Situation | Level | Message | +|---|---|---| +| `status in (success, partial)` | success | `": imported, failed"` | +| `status == failed` | danger | `": sync failed — "` | +| `SyncAlreadyRunning` caught | info | `": sync already running"` | +| Exception (unexpected) | danger | `": sync error — "` | +| `/sync-all` summary | success if all ok else danger | `"Synced riders — ok, failed"` | +| `/sync-all` with zero enabled riders | info | `"No riders to sync"` | + +`fragments/toast.html` takes `message: str` and `level: Literal["success", +"danger", "info"]`, rendering: + +```html +
    +
    {{ message }}
    +
    +``` + +`base.html` gets an empty `
    ` +right before `` so the very first toast has something to swap. + +### Auto-dismiss + +`app/web/static/app.js` gains an `htmx:oobAfterSwap` listener: when the +swapped element's id is `toast-container`, it schedules the `.toast` +child's removal after 4 seconds via a CSS class (`toast-leaving`, an +opacity/transform transition) added 300ms before the actual `remove()` +call, so it fades rather than disappearing instantly. +`prefers-reduced-motion: reduce` disables the CSS transition (the toast +still disappears at the same 4-second mark, just without animating). + +### CSS + +New `.toast-container` (fixed, top-right, stacked via flex column though +only one toast exists at a time), `.toast`, `.toast-success`, +`.toast-danger`, `.toast-info` rules using the existing color tokens +(`--success`/`--danger`/`--info` text on `--surface-raised` background, +consistent with the existing badge treatment). Existing +`button.htmx-request` / `.btn.htmx-request` rule dims the control +(`opacity: 0.6`) while a request is in flight — htmx adds/removes this +class automatically, no JS needed. + +## 4. Testing + +- `UserRepository.dashboard_row` — unit tests mirroring `dashboard_rows()` + coverage (found user returns expected fields, unknown id returns + `None`) in `tests/db/test_repositories.py`. +- Route-level tests (`tests/web/test_operations.py`, + `tests/web/test_account_web.py` or a new + `tests/web/test_live_sync_updates.py`) using the existing `TestClient` + + `fake_sync_manager` fixture, asserting on the returned HTML: the row's + `id="user-row-"` element is present with updated fields, a + `hx-swap-oob="true"` toast div is present with the expected message + class, `/sync-all` emits one OOB row per outcome, and the + already-running path returns HTTP 200 (not 409) with an info toast. +- No htmx JS itself is unit-testable from Python; the actual in-browser + swap behavior (row updates without navigation, toast appears and + disappears) is verified manually via chrome-devtools, the same way the + next-sync countdown was verified. + +## 5. Rollout + +Files touched: `app/web/static/htmx.min.js` (new, vendored), +`app/web/static/app.js`, `app/web/static/style.css`, `app/web/templates/base.html`, +`app/web/templates/dashboard.html`, `app/web/templates/account/detail.html`, +`app/web/templates/fragments/user_row.html` (new), +`app/web/templates/fragments/account_status.html` (new), +`app/web/templates/fragments/toast.html` (new), +`app/web/templates/fragments/sync_all_form.html` (new), +`app/db/repositories.py`, `app/web/operations.py`, `app/web/account.py`. +`fragments/sync_result.html` is untouched (still used by retry/MFA, +out of scope). diff --git a/tests/web/test_static_cache_busting.py b/tests/web/test_static_cache_busting.py new file mode 100644 index 0000000..0e8de7d --- /dev/null +++ b/tests/web/test_static_cache_busting.py @@ -0,0 +1,12 @@ +import re + +from fastapi.testclient import TestClient + + +def test_static_assets_are_served_with_a_cache_busting_version(client: TestClient) -> None: + response = client.get("/login") + + assert response.status_code == 200 + assert re.search(r'/static/style\.css\?v=[0-9a-f]{6,}"', response.text) + assert re.search(r'/static/app\.js\?v=[0-9a-f]{6,}"', response.text) + assert re.search(r'/static/htmx\.min\.js\?v=[0-9a-f]{6,}"', response.text)