From 450e4e935f7d2d67ec409fa657420296ada76704 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sun, 16 Aug 2026 13:32:13 +0200 Subject: [PATCH] Mark live sync updates plan tasks complete Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-16-live-sync-updates.md | 702 ++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-live-sync-updates.md diff --git a/docs/superpowers/plans/2026-08-16-live-sync-updates.md b/docs/superpowers/plans/2026-08-16-live-sync-updates.md new file mode 100644 index 0000000..57a30dc --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-live-sync-updates.md @@ -0,0 +1,702 @@ +# Live Sync Updates (HTMX) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** "Sync now" / "Sync all now" on the dashboard, and "Sync now" on the account page, update the affected rider row(s) or status block in place and show an auto-dismissing toast, instead of navigating to a separate result page. + +**Architecture:** Vendor htmx (self-hosted) as the swap mechanism. Extract the dashboard row and the account status block into reusable Jinja partials so the same markup renders both the initial page and the post-sync response. Routes always return their own primary target's current state plus zero-or-more out-of-band updates plus exactly one out-of-band toast, so every swap is safe even on a no-op path. + +**Tech Stack:** htmx v2.0.10 (vendored static file, no build step), plain CSS, a small addition to the existing vanilla `app.js`. + +**Spec:** `docs/superpowers/specs/2026-08-16-live-sync-updates-design.md` + +## Global Constraints + +- No new Python dependencies; htmx is a single vendored static JS file (spec §3). +- `fragments/sync_result.html`, the activity retry route, and the Garmin MFA route are untouched — out of scope (spec §2). +- Every htmx POST route returns its own primary swap target's current state (never empty) plus exactly one toast; `/sync-all` additionally returns one OOB row per outcome with a resolvable `user_id` (spec §3). +- `POST /users/{id}/sync` and `POST /account/sync` return HTTP 200 for the "already running" case now (previously 409) — existing tests for that behavior must be updated to match, per spec §2. +- htmx's own swap/toast behavior has no meaningful Python-level test; it is verified manually via chrome-devtools, matching how the next-sync countdown was verified (spec §4). + +--- + +### Task 1: `UserRepository.dashboard_row` + +**Files:** +- Modify: `app/db/repositories.py` +- Test: `tests/db/test_repositories.py` + +**Interfaces:** +- Produces: `UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`, used by Task 4 and Task 5's routes. + +- [x] **Step 1: Write the failing tests** + +Add to `tests/db/test_repositories.py`: + +```python +def test_dashboard_row_returns_row_for_known_user(user_repository) -> None: + user = _make_user(user_repository, "Alex") + + row = user_repository.dashboard_row(user.id) + + assert row is not None + assert row.id == user.id + assert row.name == "Alex" + + +def test_dashboard_row_returns_none_for_unknown_user(user_repository) -> None: + row = user_repository.dashboard_row(999) + + assert row is None +``` + +- [x] **Step 2: Run to verify failure** + +Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -k dashboard_row -v` +Expected: both FAIL with `AttributeError: 'UserRepository' object has no attribute 'dashboard_row'`. + +- [x] **Step 3: Extract the shared row builder and add `dashboard_row`** + +In `app/db/repositories.py`, replace the body of `dashboard_rows` with a call to a new private helper, and add `dashboard_row`: + +```python + def dashboard_rows(self) -> list[UserDashboardRow]: + return [self._build_dashboard_row(user) for user in self.list_all()] + + def dashboard_row(self, user_id: int) -> UserDashboardRow | None: + user = self.get(user_id) + if user is None: + return None + return self._build_dashboard_row(user) + + def _build_dashboard_row(self, user: SyncUser) -> UserDashboardRow: + last_run = self.session.scalar( + select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1) + ) + last_activity = self.session.scalar( + select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1) + ) + return UserDashboardRow( + id=user.id, + name=user.name, + enabled=user.enabled, + health_state=user.health_state.value, + action_reason=user.action_reason, + last_sync_at=last_run.finished_at if last_run else None, + last_activity_name=last_activity.activity_name if last_activity else None, + last_activity_status=last_activity.status.value if last_activity else None, + ) +``` + +This is a pure refactor of the existing `dashboard_rows` loop body — behavior for `dashboard_rows()` itself must not change. + +- [x] **Step 4: Run to verify pass** + +Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -v` +Expected: all tests pass, including the two new ones and the existing `dashboard_rows`-adjacent coverage (none currently exists directly, but nothing regresses). + +- [x] **Step 5: Commit** + +```bash +git add app/db/repositories.py tests/db/test_repositories.py +git commit -m "Add UserRepository.dashboard_row for single-row refresh" +``` + +--- + +### Task 2: Vendor htmx and wire up base.html + toast/loading CSS + +**Files:** +- Create: `app/web/static/htmx.min.js` (vendored, v2.0.10) +- Modify: `app/web/templates/base.html` +- Modify: `app/web/static/style.css` +- Test: none (static asset + markup/CSS; full suite re-run at the end of this task to confirm no regressions) + +**Interfaces:** +- Produces: the `#toast-container` element and `.toast`/`.toast-success`/`.toast-danger`/`.toast-info` classes that Task 3 and Task 5's `fragments/toast.html` renders into; the `.htmx-request` dimming rule. + +- [x] **Step 1: Vendor htmx** + +Download `https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js` and save it verbatim as `app/web/static/htmx.min.js` (already fetched once this session — reuse that content; if re-fetching, confirm the response is the same v2.0.10 minified build before saving). + +- [x] **Step 2: Load htmx and add the toast container in `base.html`** + +```html + + + + + +
+``` + +(only the new `htmx.min.js` line is added — `app.js` stays second so htmx's global is present before app.js's own listeners are registered, though with `defer` both run in document order regardless of load timing) + +And right before ``: + +```html +
+ +``` + +- [x] **Step 3: Add toast and htmx-request CSS to `style.css`** + +```css +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 100; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + pointer-events: auto; + background: var(--surface-raised); + border: 1px solid var(--border); + border-left: 3px solid var(--text-muted); + border-radius: 8px; + padding: 0.6rem 0.9rem; + font-size: 0.85rem; + color: var(--text); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + max-width: 320px; + transition: opacity 200ms ease, transform 200ms ease; +} + +.toast-success { + border-left-color: var(--success); +} + +.toast-danger { + border-left-color: var(--danger); +} + +.toast-info { + border-left-color: var(--info); +} + +.toast-leaving { + opacity: 0; + transform: translateX(8px); +} + +@media (prefers-reduced-motion: reduce) { + .toast { + transition: none; + } +} + +button.htmx-request, .btn.htmx-request { + opacity: 0.6; + cursor: progress; +} +``` + +- [x] **Step 4: Run the full test suite** + +Run: `.venv/Scripts/python -m pytest tests/ -q` +Expected: same pass count as the Task 1 baseline (no route/template behavior changed yet — only a new unused static file, an unreferenced-so-far toast container, and new CSS rules). + +- [x] **Step 5: Commit** + +```bash +git add app/web/static/htmx.min.js app/web/templates/base.html app/web/static/style.css +git commit -m "Vendor htmx and add toast/loading-state CSS" +``` + +--- + +### Task 3: Dashboard row and sync-all-form partials + +**Files:** +- Create: `app/web/templates/fragments/user_row.html` +- Create: `app/web/templates/fragments/sync_all_form.html` +- Create: `app/web/templates/fragments/toast.html` +- Modify: `app/web/templates/dashboard.html` +- Test: `tests/web/test_dashboard_summary.py` (existing tests must keep passing — they assert on stat-tile markup, not row markup, but re-run to confirm) + +**Interfaces:** +- Consumes: `UserDashboardRow` fields (spec §3), `csrf_token`. +- Produces: `fragments/user_row.html` renders one `
  • `, accepting `row`, `csrf_token`, `oob` (default `False`) — Task 4's route renders this same template standalone. `fragments/toast.html` accepts `message`, `level` — Task 4 and Task 5 both render it standalone. + +- [x] **Step 1: Create `fragments/user_row.html`** + +```html +
  • +
    + {{ row.name }} +
    + {{ row.health_state.replace("_", " ") }} + {{ "enabled" if row.enabled else "disabled" }} + last sync: {{ row.last_sync_at or "-" }} + last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }}) +
    + {% if row.action_reason == "garmin_mfa_required" %} + Garmin MFA required — resolve + {% elif row.action_reason == "mywhoosh_device_conflict" %} + MyWhoosh account logged in on another device — log out there, then retry + {% endif %} +
    + +
  • +``` + +- [x] **Step 2: Create `fragments/sync_all_form.html`** + +```html +
    + + +
    +``` + +- [x] **Step 3: Create `fragments/toast.html`** + +```html +
    +
    {{ message }}
    +
    +``` + +- [x] **Step 4: Update `dashboard.html` to use the partials** + +Replace the `
    ` block inside `.page-actions` with: + +```html + {% include "fragments/sync_all_form.html" %} +``` + +Replace the `
  • ...
  • ` block inside the `{% for row in rows %}` loop with: + +```html + {% include "fragments/user_row.html" %} +``` + +(the `{% else %}No users yet.{% endif %}` branch is unchanged) + +- [x] **Step 5: Run the full test suite** + +Run: `.venv/Scripts/python -m pytest tests/ -q` +Expected: same pass count as Task 2's baseline — this is a pure template refactor (the `{% include %}` inherits `row`/`csrf_token` from the enclosing loop/page context automatically), so no existing assertion on dashboard content should break. If `tests/web/test_dashboard_summary.py` or any dashboard test fails, stop and inspect — it means the include isn't inheriting context as expected. + +- [x] **Step 6: Commit** + +```bash +git add app/web/templates/fragments/user_row.html app/web/templates/fragments/sync_all_form.html app/web/templates/fragments/toast.html app/web/templates/dashboard.html +git commit -m "Extract dashboard row and sync-all form into reusable partials" +``` + +--- + +### Task 4: Live-update the dashboard sync routes + +**Files:** +- Modify: `app/web/operations.py` +- Test: `tests/web/test_operations.py` + +**Interfaces:** +- Consumes: `UserRepository.dashboard_row` (Task 1), `fragments/user_row.html` / `fragments/sync_all_form.html` / `fragments/toast.html` (Task 3). +- Produces: `_toast_html(message: str, level: str) -> str` and `_outcome_toast(outcome, label: str) -> tuple[str, str]`, imported by Task 5's `app/web/account.py`. + +- [x] **Step 1: Write the failing tests** + +Replace the existing `test_manual_sync_reports_already_running` in `tests/web/test_operations.py` (the 409 behavior is intentionally removed — see Global Constraints) and add new coverage: + +```python +def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None: + with app.state.session_factory() as session: + from app.db.repositories import UserRepository + user = UserRepository(session).create( + name="Alex", + enabled=True, + mywhoosh_email_enc="mw", + mywhoosh_password_enc="mw-pw", + garmin_email_enc="g", + garmin_password_enc="g-pw", + ) + user_id = user.id + + response = authenticated_client.post( + f"/users/{user_id}/sync", + data={"csrf_token": authenticated_client.csrf_token}, + ) + + assert response.status_code == 200 + assert f'id="user-row-{user_id}"' in response.text + assert 'hx-swap-oob="true"' in response.text # the toast + assert "Alex" in response.text + assert "0 imported, 0 failed" in response.text + + +def test_manual_sync_reports_already_running_as_toast(authenticated_client, fake_sync_manager) -> None: + fake_sync_manager.raise_already_running = True + + response = authenticated_client.post( + "/users/1/sync", + data={"csrf_token": authenticated_client.csrf_token}, + ) + + assert response.status_code == 200 + assert "already running" in response.text.lower() + + +def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None: + from app.sync.states import SyncOutcome + + with app.state.session_factory() as session: + from app.db.repositories import UserRepository + user = UserRepository(session).create( + name="Alex", + enabled=True, + mywhoosh_email_enc="mw", + mywhoosh_password_enc="mw-pw", + garmin_email_enc="g", + garmin_password_enc="g-pw", + ) + user_id = user.id + + async def fake_sync_all_enabled(): + return [SyncOutcome(user_id=user_id, status="success", discovered=2, imported=2, skipped=0, failed=0)] + + fake_sync_manager.sync_all_enabled = fake_sync_all_enabled + + response = authenticated_client.post( + "/sync-all", + data={"csrf_token": authenticated_client.csrf_token}, + ) + + assert response.status_code == 200 + assert f'id="user-row-{user_id}"' in response.text + assert "Synced 1 riders" in response.text + + +def test_sync_all_shows_toast_when_nothing_to_sync(authenticated_client, fake_sync_manager) -> None: + response = authenticated_client.post( + "/sync-all", + data={"csrf_token": authenticated_client.csrf_token}, + ) + + assert response.status_code == 200 + assert "No riders to sync" in response.text +``` + +Remove the old `test_manual_sync_reports_already_running` test (it asserted `status_code == 409`, which this task intentionally changes). + +- [x] **Step 2: Run to verify failure** + +Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v` +Expected: the four new/changed tests FAIL (old route still returns `fragments/sync_result.html` and a 409 for already-running); other existing tests in the file still pass unchanged. + +- [x] **Step 3: Rewrite the routes** + +In `app/web/operations.py`, add two module-level helpers near the top (after `_normalize_outcome`): + +```python +def _toast_html(message: str, level: str) -> str: + return templates.get_template("fragments/toast.html").render(message=message, level=level) + + +def _outcome_toast(outcome, label: str) -> tuple[str, str]: + if outcome.status in ("success", "partial"): + return f"{label}: {outcome.imported} imported, {outcome.failed} failed", "success" + return f"{label}: sync failed — {outcome.message or 'unknown error'}", "danger" +``` + +Replace `manual_sync`: + +```python +@router.post("/users/{user_id}/sync", response_class=HTMLResponse) +async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)): + require_admin(request) + validate_csrf(request, csrf_token) + token = ensure_csrf_token(request) + try: + outcome = await request.app.state.sync_manager.sync_user(user_id) + with request.app.state.session_factory() as session: + row = UserRepository(session).dashboard_row(user_id) + label = row.name if row is not None else f"Rider #{user_id}" + message, level = _outcome_toast(outcome, label) + except SyncAlreadyRunning: + with request.app.state.session_factory() as session: + row = UserRepository(session).dashboard_row(user_id) + label = row.name if row is not None else f"Rider #{user_id}" + message, level = f"{label}: sync already running", "info" + row_html = templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=False) if row else "" + return HTMLResponse(row_html + _toast_html(message, level)) +``` + +Replace `manual_sync_all`: + +```python +@router.post("/sync-all", response_class=HTMLResponse) +async def manual_sync_all(request: Request, csrf_token: str = Form(...)): + require_admin(request) + validate_csrf(request, csrf_token) + outcomes = await request.app.state.sync_manager.sync_all_enabled() + token = ensure_csrf_token(request) + + parts = [templates.get_template("fragments/sync_all_form.html").render(csrf_token=token)] + + ok = 0 + failed = 0 + with request.app.state.session_factory() as session: + repository = UserRepository(session) + for item in outcomes: + normalized = _normalize_outcome(item) + if normalized["status"] in ("success", "partial"): + ok += 1 + else: + failed += 1 + user_id = normalized["user_id"] + if user_id is not None: + row = repository.dashboard_row(user_id) + if row is not None: + parts.append( + templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=True) + ) + + if not outcomes: + parts.append(_toast_html("No riders to sync", "info")) + else: + level = "success" if failed == 0 else "danger" + parts.append(_toast_html(f"Synced {len(outcomes)} riders — {ok} ok, {failed} failed", level)) + + return HTMLResponse("".join(parts)) +``` + +Update the two remaining imports at the top of `app/web/operations.py`: `UserRepository` is already imported; `ensure_csrf_token` is already imported alongside `validate_csrf`. + +- [x] **Step 4: Run to verify pass** + +Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v` +Expected: all pass. + +- [x] **Step 5: Run the full test suite** + +Run: `.venv/Scripts/python -m pytest tests/ -q` +Expected: same pass count as Task 3's baseline plus the net new tests in this task, minus the one removed 409 test (net +3 tests). No unrelated regressions. + +- [x] **Step 6: Commit** + +```bash +git add app/web/operations.py tests/web/test_operations.py +git commit -m "Live-update dashboard rows and show toasts after sync actions" +``` + +--- + +### Task 5: Live-update the account page sync route + +**Files:** +- Create: `app/web/templates/fragments/account_status.html` +- Modify: `app/web/templates/account/detail.html` +- Modify: `app/web/account.py` +- Test: `tests/web/test_account_web.py` + +**Interfaces:** +- Consumes: `_toast_html`, `_outcome_toast` (Task 4, imported from `app.web.operations`). +- Produces: nothing consumed by a later task. + +- [x] **Step 1: Create `fragments/account_status.html`** + +```html +
    +
    Status
    +
    {{ user.health_state.value.replace("_", " ") }}
    + +
    MyWhoosh state
    +
    {{ user.mywhoosh_state }}
    + +
    Garmin state
    +
    {{ user.garmin_state }}
    + +
    Action reason
    +
    {{ user.action_reason or "-" }}
    +
    +``` + +- [x] **Step 2: Update `account/detail.html`** + +Replace the `
    ...
    ` block with: + +```html +
    + {% include "fragments/account_status.html" %} +
    +``` + +Replace the "Sync now" form in `.page-actions`: + +```html + + + +
    +``` + +- [x] **Step 3: Write the failing tests** + +Update `test_account_sync_reports_already_running` in `tests/web/test_account_web.py`: + +```python +def test_account_sync_reports_already_running_as_toast(app, client: TestClient, fake_sync_manager) -> None: + create_user_via_admin(client) + account_login(client, email="max@mywhoosh.example", password="mw-secret") + app.state.sync_manager = fake_sync_manager + fake_sync_manager.raise_already_running = True + + page = client.get("/account") + csrf = extract_csrf(page.text) + response = client.post("/account/sync", data={"csrf_token": csrf}) + + assert response.status_code == 200 + assert "already running" in response.text.lower() +``` + +Add: + +```python +def test_account_sync_updates_status_block_and_shows_toast(app, client: TestClient, fake_sync_manager) -> None: + create_user_via_admin(client) + account_login(client, email="max@mywhoosh.example", password="mw-secret") + app.state.sync_manager = fake_sync_manager + + page = client.get("/account") + csrf = extract_csrf(page.text) + response = client.post("/account/sync", data={"csrf_token": csrf}) + + assert response.status_code == 200 + assert 'id="account-status"' in response.text + assert 'hx-swap-oob="true"' in response.text + assert "0 imported, 0 failed" in response.text +``` + +- [x] **Step 4: Run to verify failure** + +Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v` +Expected: the new/changed tests FAIL (route still returns `fragments/sync_result.html` / 409). + +- [x] **Step 5: Rewrite `account_sync` in `app/web/account.py`** + +```python +from app.web.operations import _normalize_outcome, _outcome_toast, _toast_html +``` + +(add `_outcome_toast, _toast_html` to the existing import line from `app.web.operations`) + +```python +@router.post("/account/sync", response_class=HTMLResponse) +async def account_sync(request: Request, csrf_token: str = Form(...)): + user_id = require_self_service(request) + validate_csrf(request, csrf_token) + try: + outcome = await request.app.state.sync_manager.sync_user(user_id) + with request.app.state.session_factory() as session: + user = UserRepository(session).get(user_id) + label = user.name if user is not None else "Account" + message, level = _outcome_toast(outcome, label) + except SyncAlreadyRunning: + with request.app.state.session_factory() as session: + user = UserRepository(session).get(user_id) + label = user.name if user is not None else "Account" + message, level = f"{label}: sync already running", "info" + status_html = ( + templates.get_template("fragments/account_status.html").render(user=user) if user is not None else "" + ) + return HTMLResponse(status_html + _toast_html(message, level)) +``` + +- [x] **Step 6: Run to verify pass** + +Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v` +Expected: all pass. + +- [x] **Step 7: Run the full test suite** + +Run: `.venv/Scripts/python -m pytest tests/ -q` +Expected: same pass count as Task 4's baseline plus this task's net new tests, no unrelated regressions. + +- [x] **Step 8: Commit** + +```bash +git add app/web/templates/fragments/account_status.html app/web/templates/account/detail.html app/web/account.py tests/web/test_account_web.py +git commit -m "Live-update the account page status block after sync" +``` + +--- + +### Task 6: Toast auto-dismiss and manual browser verification + +**Files:** +- Modify: `app/web/static/app.js` +- Test: none (see Global Constraints — manual verification) + +**Interfaces:** +- Consumes: the `#toast-container` element (Task 2) and the `hx-swap-oob` toast fragments (Task 4, Task 5) that htmx swaps into it, firing its `htmx:oobAfterSwap` event. + +- [x] **Step 1: Add the auto-dismiss listener to `app.js`** + +Append to `app/web/static/app.js` (after the existing `DOMContentLoaded` listener, as a new top-level statement): + +```js +document.body.addEventListener("htmx:oobAfterSwap", (event) => { + if (event.detail.target.id !== "toast-container") { + return; + } + const toast = event.detail.target.querySelector(".toast"); + if (!toast) { + return; + } + setTimeout(() => { + toast.classList.add("toast-leaving"); + setTimeout(() => toast.remove(), 300); + }, 4000); +}); +``` + +- [x] **Step 2: Manually verify in a real browser via chrome-devtools** + +Start the app locally (same approach as prior manual verifications). Log in as admin, add a rider, then: +1. Click "Sync now" on the rider's row — confirm the row updates in place (no navigation, URL stays `/`), a toast appears top-right, and it fades out and disappears after ~4 seconds. +2. Click "Sync all now" — confirm the button re-renders, the row updates, and a summary toast appears. +3. Log in via `/account-login` as the same rider, click "Sync now" on the account page — confirm the status block updates in place and a toast appears. +4. Check the DevTools console (`list_console_messages`) for errors after each of the above. +5. Take a screenshot showing a toast visible on the dashboard. + +Expected: no full-page navigation for any of the three actions, no console errors, toast appears and later disappears. + +- [x] **Step 3: Commit** + +```bash +git add app/web/static/app.js +git commit -m "Auto-dismiss sync toasts after a few seconds" +``` + +--- + +### Task 7: Final full-suite regression check + +**Files:** +- None modified — verification only. + +- [x] **Step 1: Run the full Python test suite** + +Run: `.venv/Scripts/python -m pytest tests/ -q` +Expected: baseline pass count (from before this plan) plus this plan's net new/changed tests, with the same single pre-existing unrelated Windows file-permission failure (`test_tokenstore_round_trip_and_permissions`) and nothing else. + +- [x] **Step 2: Report completion to the user** + +Summarize what changed and point at the Task 6 Step 2 screenshot as evidence.