Compare commits

..

9 Commits

Author SHA1 Message Date
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
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
Bastian Wagner
13864a77f7 Auto-dismiss sync toasts after a few seconds
Fixes two issues found during manual verification:
- fragments/toast.html was missing class="toast-container" on the
  swapped-in element, so the container lost its fixed-position
  styling after the first swap.
- htmx:oobAfterSwap's event.detail.target is the *old* element that
  just got replaced (outerHTML oob-swaps detach it), so the dismiss
  timer must look up the live #toast-container by id instead of
  trusting that stale reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:32:03 +02:00
Bastian Wagner
eb97374578 Live-update the account page status block after sync
Mirrors the dashboard's row update: "Sync now" on the account page
refreshes the status block in place and shows a toast, instead of
navigating to a separate result page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:18:23 +02:00
Bastian Wagner
8d73dea7dd Live-update dashboard rows and show toasts after sync actions
"Sync now" and "Sync all now" now return the freshly reloaded rider
row(s) plus an out-of-band toast instead of navigating to a separate
result page. The "sync already running" case is a 200 + info toast
now instead of a 409 special case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:16:10 +02:00
Bastian Wagner
cc69b3ebb6 Extract dashboard row and sync-all form into reusable partials
These render the initial dashboard page today and will also be
rendered standalone by the sync routes in the next commits, so the
same markup drives both the full page and the post-sync response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:14:08 +02:00
Bastian Wagner
59b39f10cb Vendor htmx and add toast/loading-state CSS
Self-hosted htmx v2.0.10 (no CDN) plus the toast container and CSS
this and the following tasks need for in-place sync updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:12:49 +02:00
Bastian Wagner
f70f511907 Add UserRepository.dashboard_row for single-row refresh
Extracts the dashboard_rows() loop body into a shared
_build_dashboard_row helper so a single rider's row can be re-queried
after a sync action, without changing dashboard_rows()'s behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:11:29 +02:00
Bastian Wagner
a0890126bc Add dashboard summary tiles
Shows rider count, activities imported in the last 7 days, 7-day
sync success rate, and how many riders currently need attention,
right above the rider list where an admin looks first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:48:44 +02:00
21 changed files with 1457 additions and 80 deletions

View File

@@ -1,13 +1,14 @@
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import and_, or_, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db.models import (
Activity,
ActivityStatus,
HealthState,
SchedulerSettings,
SyncRun,
SyncRunStatus,
@@ -19,6 +20,15 @@ from app.db.models import (
_SCHEDULER_SETTINGS_ID = 1
@dataclass(frozen=True)
class DashboardSummary:
rider_total: int
rider_enabled: int
imported_recent: int
success_rate_recent: float | None
action_required_count: int
@dataclass(frozen=True)
class UserDashboardRow:
id: int
@@ -57,26 +67,64 @@ class UserRepository:
return user
def dashboard_rows(self) -> list[UserDashboardRow]:
users = self.list_all()
rows = []
for user in users:
last_run = self.session.scalar(
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
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,
)
def dashboard_summary(self, *, since: datetime) -> DashboardSummary:
rider_total = self.session.scalar(select(func.count()).select_from(SyncUser)) or 0
rider_enabled = self.session.scalar(
select(func.count()).select_from(SyncUser).where(SyncUser.enabled.is_(True))
) or 0
action_required_count = self.session.scalar(
select(func.count()).select_from(SyncUser).where(SyncUser.health_state == HealthState.ACTION_REQUIRED)
) or 0
imported_recent = self.session.scalar(
select(func.coalesce(func.sum(SyncRun.imported_count), 0)).where(SyncRun.started_at >= since)
) or 0
finished_runs = list(
self.session.scalars(
select(SyncRun).where(SyncRun.started_at >= since, SyncRun.finished_at.is_not(None))
)
last_activity = self.session.scalar(
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
)
if finished_runs:
successful = sum(
1 for run in finished_runs if run.status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL)
)
rows.append(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,
))
return rows
success_rate_recent = (successful / len(finished_runs)) * 100
else:
success_rate_recent = None
return DashboardSummary(
rider_total=rider_total,
rider_enabled=rider_enabled,
imported_recent=imported_recent,
success_rate_recent=success_rate_recent,
action_required_count=action_required_count,
)
class ActivityRepository:

View File

@@ -7,7 +7,7 @@ from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncAlreadyRunning
from app.web.operations import _normalize_outcome
from app.web.operations import _outcome_toast, _toast_html
from app.web.routes import templates
router = APIRouter()
@@ -92,11 +92,19 @@ async def account_sync(request: Request, csrf_token: str = Form(...)):
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:
return HTMLResponse("Sync already running for this user", status_code=409)
return templates.TemplateResponse(
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]}
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))
@router.get("/account/edit", response_class=HTMLResponse)

View File

@@ -14,6 +14,16 @@ router = APIRouter()
APP_VERSION = "1.0.0"
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"
def _normalize_outcome(item):
if isinstance(item, Exception):
return {
@@ -40,13 +50,24 @@ def _normalize_outcome(item):
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:
return HTMLResponse("Sync already running for this user", status_code=409)
return templates.TemplateResponse(
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]}
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 is not None
else ""
)
return HTMLResponse(row_html + _toast_html(message, level))
@router.post("/sync-all", response_class=HTMLResponse)
@@ -54,9 +75,35 @@ 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()
return templates.TemplateResponse(
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(o) for o in outcomes]}
)
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
outcome_user_id = normalized["user_id"]
if outcome_user_id is not None:
row = repository.dashboard_row(outcome_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))
@router.post("/users/{user_id}/garmin-mfa", response_class=HTMLResponse)

View File

@@ -1,3 +1,5 @@
import hashlib
from datetime import timedelta
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request, status
@@ -6,15 +8,33 @@ from fastapi.templating import Jinja2Templates
from app.auth.admin import password_matches, require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import SyncUser
from app.db.models import SyncUser, utcnow
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher
from app.web.forms import UserFormData
DASHBOARD_SUMMARY_WINDOW = timedelta(days=7)
CACHE_BUSTED_STATIC_FILES = ("style.css", "app.js", "htmx.min.js")
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
def _compute_static_version(static_dir: Path) -> str:
hasher = hashlib.sha256()
for name in CACHE_BUSTED_STATIC_FILES:
try:
hasher.update((static_dir / name).read_bytes())
except FileNotFoundError:
continue
return hasher.hexdigest()[:10]
templates.env.globals["static_version"] = _compute_static_version(
Path(__file__).resolve().parent / "static"
)
def _next_sync_tick(request: Request):
scheduler = getattr(request.app.state, "scheduler", None)
return getattr(scheduler, "next_tick", None)
@@ -70,11 +90,13 @@ def login(
def dashboard(request: Request):
require_admin(request)
with request.app.state.session_factory() as session:
rows = UserRepository(session).dashboard_rows()
repository = UserRepository(session)
rows = repository.dashboard_rows()
summary = repository.dashboard_summary(since=utcnow() - DASHBOARD_SUMMARY_WINDOW)
return templates.TemplateResponse(
request,
"dashboard.html",
{"rows": rows, "csrf_token": ensure_csrf_token(request)},
{"rows": rows, "summary": summary, "csrf_token": ensure_csrf_token(request)},
)

View File

@@ -37,3 +37,21 @@ function startSyncCountdown(el) {
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown);
});
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
if (event.detail.target.id !== "toast-container") {
return;
}
// event.detail.target is the *old* element htmx just swapped out (an
// outerHTML oob-swap detaches it), so look the live one up by id
// rather than trusting that reference.
const container = document.getElementById("toast-container");
const toast = container ? container.querySelector(".toast") : null;
if (!toast) {
return;
}
setTimeout(() => {
toast.classList.add("toast-leaving");
setTimeout(() => toast.remove(), 300);
}, 4000);
});

1
app/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -116,6 +116,97 @@ h2 {
flex-wrap: wrap;
}
.stat-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.stat-tile {
display: flex;
flex-direction: column;
gap: 0.3rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1.1rem;
}
.stat-tile-label {
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stat-tile-value {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-size: 1.6rem;
font-weight: 700;
color: var(--accent);
}
.stat-tile-sub {
color: var(--text-muted);
font-size: 0.78rem;
}
.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;
}
.card {
background: var(--surface);
border: 1px solid var(--border);

View File

@@ -6,7 +6,8 @@
<h1>{{ user.name }}</h1>
<div class="page-actions">
<a class="btn secondary" href="/account/edit">Edit</a>
<form method="post" action="/account/sync" class="inline-form">
<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>
@@ -17,19 +18,7 @@
</div>
<div class="card">
<dl class="info-grid">
<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>
{% include "fragments/account_status.html" %}
</div>
{% if user.action_reason == "mywhoosh_device_conflict" %}

View File

@@ -8,8 +8,9 @@
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16.png">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js" defer></script>
<link rel="stylesheet" href="/static/style.css?v={{ static_version }}">
<script src="/static/htmx.min.js?v={{ static_version }}" defer></script>
<script src="/static/app.js?v={{ static_version }}" defer></script>
</head>
<body>
<header class="topbar">
@@ -33,5 +34,6 @@
<main class="container">
{% block content %}{% endblock %}
</main>
<div id="toast-container" class="toast-container"></div>
</body>
</html>

View File

@@ -5,38 +5,34 @@
{% block content %}
<h1>Dashboard</h1>
<div class="stat-tiles">
<div class="stat-tile">
<span class="stat-tile-label">Riders</span>
<span class="stat-tile-value">{{ summary.rider_total }}</span>
<span class="stat-tile-sub">{{ summary.rider_enabled }} enabled</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Imported &middot; 7d</span>
<span class="stat-tile-value">{{ summary.imported_recent }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Success rate &middot; 7d</span>
<span class="stat-tile-value">{% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}&ndash;{% endif %}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Action required</span>
<span class="stat-tile-value">{{ summary.action_required_count }}</span>
</div>
</div>
<div class="page-actions">
<a class="btn secondary" href="/users/new">Add user</a>
<form method="post" action="/sync-all" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
{% include "fragments/sync_all_form.html" %}
</div>
<ul class="user-list">
{% for row in rows %}
<li class="card user-card">
<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">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Sync now</button>
</form>
</div>
</li>
{% include "fragments/user_row.html" %}
{% else %}
<li class="card empty-state">No users yet.</li>
{% endfor %}

View File

@@ -0,0 +1,13 @@
<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>

View File

@@ -0,0 +1,5 @@
<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>

View File

@@ -0,0 +1,3 @@
<div id="toast-container" class="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>

View File

@@ -0,0 +1,24 @@
<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>

View File

@@ -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
<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>`:
```html
<div id="toast-container" class="toast-container"></div>
</body>
```
- [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 `<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.
- [x] **Step 1: Create `fragments/user_row.html`**
```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>
```
- [x] **Step 2: Create `fragments/sync_all_form.html`**
```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>
```
- [x] **Step 3: Create `fragments/toast.html`**
```html
<div id="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>
```
- [x] **Step 4: Update `dashboard.html` to use the partials**
Replace the `<form ... action="/sync-all" ...>` block inside `.page-actions` with:
```html
{% include "fragments/sync_all_form.html" %}
```
Replace the `<li class="card user-card">...</li>` 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
<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>
```
- [x] **Step 2: Update `account/detail.html`**
Replace the `<div class="card"><dl class="info-grid">...</dl></div>` block with:
```html
<div class="card">
{% include "fragments/account_status.html" %}
</div>
```
Replace the "Sync now" form in `.page-actions`:
```html
<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>
```
- [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.

View File

@@ -0,0 +1,187 @@
# 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:
```html
<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).

View File

@@ -1,4 +1,6 @@
from app.db.models import ActivityStatus, HealthState
from datetime import datetime, timedelta, timezone
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
def test_create_two_independent_users(db_session, user_repository) -> None:
@@ -129,3 +131,95 @@ def test_scheduler_settings_update_persists_all_fields(scheduler_settings_reposi
assert reloaded.night_start_hour == 20
assert reloaded.day_interval_minutes == 10
assert reloaded.night_interval_minutes == 45
def _make_user(repo, name, *, enabled=True, health_state=HealthState.HEALTHY):
return repo.create(
name=name,
enabled=enabled,
health_state=health_state,
mywhoosh_email_enc=f"mw-{name}",
mywhoosh_password_enc=f"mw-pw-{name}",
garmin_email_enc=f"g-{name}",
garmin_password_enc=f"g-pw-{name}",
)
def test_dashboard_summary_counts_riders_total_and_enabled(user_repository) -> None:
_make_user(user_repository, "Alex", enabled=True)
_make_user(user_repository, "Jamie", enabled=True)
_make_user(user_repository, "Paused", enabled=False)
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
assert summary.rider_total == 3
assert summary.rider_enabled == 2
def test_dashboard_summary_counts_action_required_riders(user_repository) -> None:
_make_user(user_repository, "Healthy", health_state=HealthState.HEALTHY)
_make_user(user_repository, "Blocked", health_state=HealthState.ACTION_REQUIRED)
_make_user(user_repository, "AlsoBlocked", health_state=HealthState.ACTION_REQUIRED)
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
assert summary.action_required_count == 2
def test_dashboard_summary_sums_imported_count_within_window_only(user_repository, sync_run_repository) -> None:
user = _make_user(user_repository, "Alex")
now = datetime.now(timezone.utc)
recent_run = sync_run_repository.start(user.id)
recent_run.started_at = now - timedelta(days=1)
sync_run_repository.finish(
recent_run.id, status=SyncRunStatus.SUCCESS, discovered=5, imported=5, skipped=0, failed=0
)
old_run = sync_run_repository.start(user.id)
old_run.started_at = now - timedelta(days=30)
sync_run_repository.finish(
old_run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0
)
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
assert summary.imported_recent == 5
def test_dashboard_summary_computes_success_rate_from_finished_runs_in_window(
user_repository, sync_run_repository
) -> None:
user = _make_user(user_repository, "Alex")
now = datetime.now(timezone.utc)
for status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL, SyncRunStatus.FAILED, SyncRunStatus.FAILED):
run = sync_run_repository.start(user.id)
run.started_at = now - timedelta(hours=1)
sync_run_repository.finish(run.id, status=status, discovered=1, imported=0, skipped=0, failed=1)
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
assert summary.success_rate_recent == 50.0
def test_dashboard_summary_success_rate_is_none_without_finished_runs_in_window(user_repository) -> None:
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
assert summary.success_rate_recent is None
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

View File

@@ -289,7 +289,7 @@ def test_account_sync_triggers_own_user_only(app, client: TestClient, fake_sync_
assert fake_sync_manager.user_calls == [user_id]
def test_account_sync_reports_already_running(app, client: TestClient, fake_sync_manager) -> None:
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
@@ -299,10 +299,25 @@ def test_account_sync_reports_already_running(app, client: TestClient, fake_sync
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 409
assert response.status_code == 200
assert "already running" in response.text.lower()
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
def test_account_sync_requires_login(client: TestClient) -> None:
response = client.post("/account/sync", data={"csrf_token": "whatever"}, follow_redirects=False)
assert response.status_code == 303

View File

@@ -0,0 +1,43 @@
from datetime import datetime, timedelta, timezone
from app.db.models import HealthState, SyncRunStatus
from app.db.repositories import SyncRunRepository, UserRepository
def _seed_user(session, name, *, enabled=True, health_state=HealthState.HEALTHY):
return UserRepository(session).create(
name=name,
enabled=enabled,
health_state=health_state,
mywhoosh_email_enc=f"mw-{name}",
mywhoosh_password_enc=f"mw-pw-{name}",
garmin_email_enc=f"g-{name}",
garmin_password_enc=f"g-pw-{name}",
)
def test_dashboard_shows_summary_tiles(app, authenticated_client) -> None:
with app.state.session_factory() as session:
alex = _seed_user(session, "Alex", enabled=True)
_seed_user(session, "Jamie", enabled=False, health_state=HealthState.ACTION_REQUIRED)
run_repo = SyncRunRepository(session)
run = run_repo.start(alex.id)
run.started_at = datetime.now(timezone.utc) - timedelta(hours=2)
run_repo.finish(run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0)
response = authenticated_client.get("/")
assert response.status_code == 200
assert '<span class="stat-tile-value">2</span>' in response.text
assert "1 enabled" in response.text
assert '<span class="stat-tile-value">3</span>' in response.text
assert '<span class="stat-tile-value">100%</span>' in response.text
assert '<span class="stat-tile-value">1</span>' in response.text
def test_dashboard_shows_dash_for_success_rate_without_recent_runs(authenticated_client) -> None:
response = authenticated_client.get("/")
assert response.status_code == 200
assert '<span class="stat-tile-value">&ndash;</span>' in response.text

View File

@@ -1,6 +1,20 @@
from fastapi.testclient import TestClient
from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository
from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository, UserRepository
from app.sync.states import SyncOutcome
def _create_user(app, name="Alex") -> int:
with app.state.session_factory() as session:
user = UserRepository(session).create(
name=name,
enabled=True,
mywhoosh_email_enc="mw",
mywhoosh_password_enc="mw-pw",
garmin_email_enc="g",
garmin_password_enc="g-pw",
)
return user.id
def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
@@ -12,13 +26,28 @@ def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manage
assert fake_sync_manager.user_calls == [1]
def test_manual_sync_reports_already_running(authenticated_client, fake_sync_manager) -> None:
def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None:
user_id = _create_user(app, "Alex")
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
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 == 409
assert response.status_code == 200
assert "already running" in response.text.lower()
@@ -31,6 +60,34 @@ def test_sync_all_calls_shared_manager(authenticated_client, fake_sync_manager)
assert fake_sync_manager.all_calls == 1
def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None:
user_id = _create_user(app, "Alex")
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
def test_manual_sync_requires_admin(client: TestClient) -> None:
response = client.post(
"/users/1/sync",

View File

@@ -0,0 +1,12 @@
import re
from fastapi.testclient import TestClient
def test_static_assets_are_served_with_a_cache_busting_version(client: TestClient) -> None:
response = client.get("/login")
assert response.status_code == 200
assert re.search(r'/static/style\.css\?v=[0-9a-f]{6,}"', response.text)
assert re.search(r'/static/app\.js\?v=[0-9a-f]{6,}"', response.text)
assert re.search(r'/static/htmx\.min\.js\?v=[0-9a-f]{6,}"', response.text)