Files
mywhoosh2garmin/docs/superpowers/specs/2026-08-16-live-sync-updates-design.md
Bastian Wagner 8a23147dc8 Add cache-busting version query to static assets
Cloudflare was caching /static/style.css and /static/app.js at the
edge for up to 4 hours (its default Browser Cache TTL, since the app
sets no explicit Cache-Control), so deploys could look like they
hadn't landed even though the origin was already up to date. A
content hash of style.css/app.js/htmx.min.js, computed once at
startup, is now appended as ?v=<hash> to their URLs in base.html, so
every deploy that changes those files produces new, never-cached
URLs and needs no manual cache purge.

Also commits the live-sync-updates design spec, which was written
but never staged earlier in the session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:51:28 +02:00

188 lines
8.1 KiB
Markdown

# 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 `<script src="/static/htmx.min.js" defer></script>`, 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
`<li class="card user-card" id="user-row-{{ row.id }}">...</li>`, 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 `<li>` 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
`<dl class="info-grid" id="account-status">...</dl>` 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 `<form>`
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 | `"<name>: <imported> imported, <failed> failed"` |
| `status == failed` | danger | `"<name>: sync failed — <message or 'unknown error'>"` |
| `SyncAlreadyRunning` caught | info | `"<name>: sync already running"` |
| Exception (unexpected) | danger | `"<name>: sync error — <message>"` |
| `/sync-all` summary | success if all ok else danger | `"Synced <n> riders — <ok> ok, <failed> 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
<div id="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>
```
`base.html` gets an empty `<div id="toast-container" class="toast-container"></div>`
right before `</body>` 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-<id>"` 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).