Compare commits

...

8 Commits

Author SHA1 Message Date
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
19 changed files with 1239 additions and 78 deletions

View File

@@ -1,13 +1,14 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from sqlalchemy import and_, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import ( from app.db.models import (
Activity, Activity,
ActivityStatus, ActivityStatus,
HealthState,
SchedulerSettings, SchedulerSettings,
SyncRun, SyncRun,
SyncRunStatus, SyncRunStatus,
@@ -19,6 +20,15 @@ from app.db.models import (
_SCHEDULER_SETTINGS_ID = 1 _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) @dataclass(frozen=True)
class UserDashboardRow: class UserDashboardRow:
id: int id: int
@@ -57,16 +67,22 @@ class UserRepository:
return user return user
def dashboard_rows(self) -> list[UserDashboardRow]: def dashboard_rows(self) -> list[UserDashboardRow]:
users = self.list_all() return [self._build_dashboard_row(user) for user in self.list_all()]
rows = []
for user in users: 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( last_run = self.session.scalar(
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1) select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
) )
last_activity = self.session.scalar( last_activity = self.session.scalar(
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1) select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
) )
rows.append(UserDashboardRow( return UserDashboardRow(
id=user.id, id=user.id,
name=user.name, name=user.name,
enabled=user.enabled, enabled=user.enabled,
@@ -75,8 +91,40 @@ class UserRepository:
last_sync_at=last_run.finished_at if last_run else None, 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_name=last_activity.activity_name if last_activity else None,
last_activity_status=last_activity.status.value if last_activity else None, last_activity_status=last_activity.status.value if last_activity else None,
)) )
return rows
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))
)
)
if finished_runs:
successful = sum(
1 for run in finished_runs if run.status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL)
)
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: 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.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
from app.sync.manager import SyncAlreadyRunning 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 from app.web.routes import templates
router = APIRouter() router = APIRouter()
@@ -92,11 +92,19 @@ async def account_sync(request: Request, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
try: try:
outcome = await request.app.state.sync_manager.sync_user(user_id) 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: except SyncAlreadyRunning:
return HTMLResponse("Sync already running for this user", status_code=409) with request.app.state.session_factory() as session:
return templates.TemplateResponse( user = UserRepository(session).get(user_id)
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]} 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) @router.get("/account/edit", response_class=HTMLResponse)

View File

@@ -14,6 +14,16 @@ router = APIRouter()
APP_VERSION = "1.0.0" 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): def _normalize_outcome(item):
if isinstance(item, Exception): if isinstance(item, Exception):
return { return {
@@ -40,13 +50,24 @@ def _normalize_outcome(item):
async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)): async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)):
require_admin(request) require_admin(request)
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
token = ensure_csrf_token(request)
try: try:
outcome = await request.app.state.sync_manager.sync_user(user_id) 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: except SyncAlreadyRunning:
return HTMLResponse("Sync already running for this user", status_code=409) with request.app.state.session_factory() as session:
return templates.TemplateResponse( row = UserRepository(session).dashboard_row(user_id)
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]} 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) @router.post("/sync-all", response_class=HTMLResponse)
@@ -54,10 +75,36 @@ async def manual_sync_all(request: Request, csrf_token: str = Form(...)):
require_admin(request) require_admin(request)
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
outcomes = await request.app.state.sync_manager.sync_all_enabled() outcomes = await request.app.state.sync_manager.sync_all_enabled()
return templates.TemplateResponse( token = ensure_csrf_token(request)
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(o) for o in outcomes]}
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) @router.post("/users/{user_id}/garmin-mfa", response_class=HTMLResponse)
async def garmin_mfa(request: Request, user_id: int, csrf_token: str = Form(...), code: str = Form(...)): async def garmin_mfa(request: Request, user_id: int, csrf_token: str = Form(...), code: str = Form(...)):

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request, status from fastapi import APIRouter, Form, HTTPException, Request, status
@@ -6,11 +7,13 @@ from fastapi.templating import Jinja2Templates
from app.auth.admin import password_matches, require_admin from app.auth.admin import password_matches, require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf 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.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
from app.web.forms import UserFormData from app.web.forms import UserFormData
DASHBOARD_SUMMARY_WINDOW = timedelta(days=7)
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
@@ -70,11 +73,13 @@ def login(
def dashboard(request: Request): def dashboard(request: Request):
require_admin(request) require_admin(request)
with request.app.state.session_factory() as session: 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( return templates.TemplateResponse(
request, request,
"dashboard.html", "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.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown); 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; 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 { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@@ -6,7 +6,8 @@
<h1>{{ user.name }}</h1> <h1>{{ user.name }}</h1>
<div class="page-actions"> <div class="page-actions">
<a class="btn secondary" href="/account/edit">Edit</a> <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 }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button> <button type="submit">Sync now</button>
</form> </form>
@@ -17,19 +18,7 @@
</div> </div>
<div class="card"> <div class="card">
<dl class="info-grid"> {% include "fragments/account_status.html" %}
<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>
</div> </div>
{% if user.action_reason == "mywhoosh_device_conflict" %} {% if user.action_reason == "mywhoosh_device_conflict" %}

View File

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

View File

@@ -5,38 +5,34 @@
{% block content %} {% block content %}
<h1>Dashboard</h1> <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"> <div class="page-actions">
<a class="btn secondary" href="/users/new">Add user</a> <a class="btn secondary" href="/users/new">Add user</a>
<form method="post" action="/sync-all" class="inline-form"> {% include "fragments/sync_all_form.html" %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
</div> </div>
<ul class="user-list"> <ul class="user-list">
{% for row in rows %} {% for row in rows %}
<li class="card user-card"> {% include "fragments/user_row.html" %}
<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>
{% else %} {% else %}
<li class="card empty-state">No users yet.</li> <li class="card empty-state">No users yet.</li>
{% endfor %} {% 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

@@ -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: 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.night_start_hour == 20
assert reloaded.day_interval_minutes == 10 assert reloaded.day_interval_minutes == 10
assert reloaded.night_interval_minutes == 45 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] 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) create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret") account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager 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) csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf}) 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() 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: def test_account_sync_requires_login(client: TestClient) -> None:
response = client.post("/account/sync", data={"csrf_token": "whatever"}, follow_redirects=False) response = client.post("/account/sync", data={"csrf_token": "whatever"}, follow_redirects=False)
assert response.status_code == 303 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 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: 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] 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 fake_sync_manager.raise_already_running = True
response = authenticated_client.post( response = authenticated_client.post(
"/users/1/sync", "/users/1/sync",
data={"csrf_token": authenticated_client.csrf_token}, data={"csrf_token": authenticated_client.csrf_token},
) )
assert response.status_code == 409 assert response.status_code == 200
assert "already running" in response.text.lower() 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 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: def test_manual_sync_requires_admin(client: TestClient) -> None:
response = client.post( response = client.post(
"/users/1/sync", "/users/1/sync",