Files
mywhoosh2garmin/docs/superpowers/plans/2026-08-16-live-sync-updates.md
Bastian Wagner 450e4e935f Mark live sync updates plan tasks complete
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:32:13 +02:00

26 KiB

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.

  • Step 1: Write the failing tests

Add to tests/db/test_repositories.py:

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
  • 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'.

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

    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.

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

  • Step 5: Commit
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.

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

  • Step 2: Load htmx and add the toast container in base.html
    <link rel="stylesheet" href="/static/style.css">
    <script src="/static/htmx.min.js" defer></script>
    <script src="/static/app.js" defer></script>
</head>
<body>
    <header class="topbar">

(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 </body>:

    <div id="toast-container" class="toast-container"></div>
</body>
  • Step 3: Add toast and htmx-request CSS to style.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;
}
  • 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).

  • Step 5: Commit
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 <li id="user-row-<id>">, 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.

  • Step 1: Create fragments/user_row.html

<li class="card user-card" id="user-row-{{ row.id }}"{% if oob %} hx-swap-oob="true"{% endif %}>
    <div class="user-main">
        <a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
        <div class="user-meta">
            <span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
            <span>{{ "enabled" if row.enabled else "disabled" }}</span>
            <span>last sync: {{ row.last_sync_at or "-" }}</span>
            <span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
        </div>
        {% if row.action_reason == "garmin_mfa_required" %}
        <span class="action-required">Garmin MFA required &mdash; <a href="/users/{{ row.id }}">resolve</a></span>
        {% elif row.action_reason == "mywhoosh_device_conflict" %}
        <span class="action-required">MyWhoosh account logged in on another device &mdash; log out there, then <a href="/users/{{ row.id }}">retry</a></span>
        {% endif %}
    </div>
    <div class="user-actions">
        <form method="post" action="/users/{{ row.id }}/sync" class="inline-form"
              hx-post="/users/{{ row.id }}/sync" hx-target="closest .user-card" hx-swap="outerHTML"
              hx-disabled-elt="find button">
            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
            <button type="submit" class="secondary">Sync now</button>
        </form>
    </div>
</li>
  • Step 2: Create fragments/sync_all_form.html
<form method="post" action="/sync-all" class="inline-form"
      hx-post="/sync-all" hx-target="this" hx-swap="outerHTML" hx-disabled-elt="find button">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    <button type="submit">Sync all now</button>
</form>
  • Step 3: Create fragments/toast.html
<div id="toast-container" hx-swap-oob="true">
    <div class="toast toast-{{ level }}">{{ message }}</div>
</div>
  • Step 4: Update dashboard.html to use the partials

Replace the <form ... action="/sync-all" ...> block inside .page-actions with:

    {% include "fragments/sync_all_form.html" %}

Replace the <li class="card user-card">...</li> block inside the {% for row in rows %} loop with:

    {% include "fragments/user_row.html" %}

(the {% else %}No users yet.{% endif %} branch is unchanged)

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

  • Step 6: Commit
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.

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

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

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

  • Step 3: Rewrite the routes

In app/web/operations.py, add two module-level helpers near the top (after _normalize_outcome):

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:

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

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

  • Step 4: Run to verify pass

Run: .venv/Scripts/python -m pytest tests/web/test_operations.py -v Expected: all pass.

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

  • Step 6: Commit
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.

  • Step 1: Create fragments/account_status.html

<dl class="info-grid" id="account-status">
    <dt>Status</dt>
    <dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>

    <dt>MyWhoosh state</dt>
    <dd>{{ user.mywhoosh_state }}</dd>

    <dt>Garmin state</dt>
    <dd>{{ user.garmin_state }}</dd>

    <dt>Action reason</dt>
    <dd>{{ user.action_reason or "-" }}</dd>
</dl>
  • Step 2: Update account/detail.html

Replace the <div class="card"><dl class="info-grid">...</dl></div> block with:

<div class="card">
    {% include "fragments/account_status.html" %}
</div>

Replace the "Sync now" form in .page-actions:

    <form method="post" action="/account/sync" class="inline-form"
          hx-post="/account/sync" hx-target="#account-status" hx-swap="outerHTML" hx-disabled-elt="find button">
        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
        <button type="submit">Sync now</button>
    </form>
  • Step 3: Write the failing tests

Update test_account_sync_reports_already_running in tests/web/test_account_web.py:

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:

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

  • Step 5: Rewrite account_sync in app/web/account.py
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)

@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))
  • Step 6: Run to verify pass

Run: .venv/Scripts/python -m pytest tests/web/test_account_web.py -v Expected: all pass.

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

  • Step 8: Commit
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.

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

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);
});
  • 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.

  • Step 3: Commit
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.

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

  • Step 2: Report completion to the user

Summarize what changed and point at the Task 6 Step 2 screenshot as evidence.