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>
8.1 KiB
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 oldfragments/sync_result.htmlpage. - 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:
- 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-allform itself, which always re-renders unchanged). This makes every swap safe/idempotent — the target is never replaced with nothing. - 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, onSyncAlreadyRunning, and on any other outcome, always ends by opening a fresh session, callingUserRepository(session).dashboard_row(user_id), and renderingfragments/user_row.html(oob=False, since this row IS the primaryhx-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 onefragments/user_row.html(oob=True) per outcome with a resolvableuser_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 isfragments/account_status.htmlre-rendered from the freshly reloadedSyncUser, 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:
<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 mirroringdashboard_rows()coverage (found user returns expected fields, unknown id returnsNone) intests/db/test_repositories.py.- Route-level tests (
tests/web/test_operations.py,tests/web/test_account_web.pyor a newtests/web/test_live_sync_updates.py) using the existingTestClient+fake_sync_managerfixture, asserting on the returned HTML: the row'sid="user-row-<id>"element is present with updated fields, ahx-swap-oob="true"toast div is present with the expected message class,/sync-allemits 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).