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

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 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:

<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).