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