Compare commits

..

15 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
Bastian Wagner
9df6a619d2 Mark visual redesign plan tasks complete
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:22 +02:00
Bastian Wagner
a9a46e949f Turn the next-sync indicator into a live ticking countdown
Replaces the one-time UTC-to-local formatting with a per-second
countdown (HH:MM:SS, or MM:SS under an hour) that fits the dark
cockpit theme's instrument-panel feel, falling back to "due now" once
the target passes instead of showing a negative duration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:07 +02:00
Bastian Wagner
aa9e289185 Fix unstyled number inputs on the system scheduling form
input[type="number"] was missing from the form field selector added
in the redesign, so the scheduler settings fields stayed white-on-
white. Caught via manual browser verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:07 +02:00
Bastian Wagner
b6d4be97c1 Redesign UI with dark cockpit color and typography tokens
Replaces the light generic theme with a dark palette grounded in the
app's own subject matter (bike computer telemetry): electric-lime
accent, tabular monospace numerals for stats/timestamps, hairline
card borders instead of shadows, and visible focus rings. Every
template already shares the same classes, so no markup changes were
needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:59:58 +02:00
Bastian Wagner
a74ab95f3f Show next scheduled sync time on every page
Exposes the scheduler's next_tick in the topbar via a safe Jinja
helper (falls back to nothing if the scheduler isn't running yet),
and converts the server-rendered UTC timestamp to the visitor's local
time client-side so it reads correctly regardless of timezone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:58:53 +02:00
Bastian Wagner
5d75aa328d Add design spec for dark cockpit-style visual redesign
Documents the color/type/layout token system and the next-sync
countdown evolution agreed on with the user, ahead of implementation
planning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:48:43 +02:00
24 changed files with 2391 additions and 114 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,5 @@
import hashlib
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,15 +8,41 @@ 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)
CACHE_BUSTED_STATIC_FILES = ("style.css", "app.js", "htmx.min.js")
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) 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)
templates.env.globals["next_sync_tick"] = _next_sync_tick
def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser: def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser:
user = repository.get(user_id) user = repository.get(user_id)
if user is None: if user is None:
@@ -62,11 +90,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)},
) )

57
app/web/static/app.js Normal file
View File

@@ -0,0 +1,57 @@
function formatCountdown(remainingMs) {
if (remainingMs <= 0) {
return "due now";
}
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n) => String(n).padStart(2, "0");
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
return `${pad(minutes)}:${pad(seconds)}`;
}
function startSyncCountdown(el) {
const target = new Date(el.dataset.utc);
if (Number.isNaN(target.getTime())) {
return;
}
el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`;
let intervalId = null;
const tick = () => {
const remaining = target.getTime() - Date.now();
el.textContent = formatCountdown(remaining);
if (remaining <= 0 && intervalId !== null) {
clearInterval(intervalId);
}
};
tick();
if (target.getTime() - Date.now() > 0) {
intervalId = setInterval(tick, 1000);
}
}
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

@@ -1,23 +1,24 @@
:root { :root {
--bg: #f4f5f9; --bg: #12151a;
--surface: #ffffff; --surface: #1a1f27;
--border: #e3e6ee; --surface-raised: #232935;
--text: #1c2130; --border: #2a3038;
--text-muted: #6b7185; --text: #e7eaf0;
--primary: #4c5fd5; --text-muted: #8b93a3;
--primary-hover: #3d4dc0; --accent: #c8ff4d;
--danger: #d64550; --danger: #ff5f6d;
--danger-bg: #fdeceb; --danger-bg: rgba(255, 95, 109, 0.16);
--success: #1f9d63; --success: #c8ff4d;
--success-bg: #e7f7ee; --success-bg: rgba(200, 255, 77, 0.16);
--warning: #b8860b; --warning: #ffb454;
--warning-bg: #fdf3d9; --warning-bg: rgba(255, 180, 84, 0.16);
--neutral: #6b7185; --neutral: #8b93a3;
--neutral-bg: #eef0f5; --neutral-bg: rgba(139, 147, 163, 0.16);
--info: #2f7ec2; --info: #5fd4ff;
--info-bg: #e8f2fb; --info-bg: rgba(95, 212, 255, 0.16);
--radius: 10px; --radius: 10px;
--shadow: 0 1px 2px rgba(28, 33, 48, 0.06), 0 1px 8px rgba(28, 33, 48, 0.04); --shadow: none;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
} }
* { * {
@@ -33,7 +34,7 @@ body {
} }
a { a {
color: var(--primary); color: var(--info);
text-decoration: none; text-decoration: none;
} }
@@ -41,6 +42,14 @@ a:hover {
text-decoration: underline; text-decoration: underline;
} }
a:focus-visible,
button:focus-visible,
.btn:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.topbar { .topbar {
background: var(--surface); background: var(--surface);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -64,6 +73,13 @@ a:hover {
border-radius: 7px; border-radius: 7px;
} }
.topbar-right {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.topbar nav { .topbar nav {
display: flex; display: flex;
gap: 1.25rem; gap: 1.25rem;
@@ -76,14 +92,20 @@ a:hover {
padding: 2rem 1.5rem 4rem; padding: 2rem 1.5rem 4rem;
} }
h1, h2 {
text-transform: uppercase;
letter-spacing: 0.06em;
}
h1 { h1 {
font-size: 1.5rem; font-size: 1.4rem;
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
} }
h2 { h2 {
font-size: 1.1rem; font-size: 1rem;
margin: 2rem 0 0.75rem; margin: 2rem 0 0.75rem;
color: var(--text-muted);
} }
.page-actions { .page-actions {
@@ -94,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);
@@ -101,6 +214,24 @@ h2 {
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: 1.25rem 1.4rem; padding: 1.25rem 1.4rem;
margin-bottom: 1rem; margin-bottom: 1rem;
animation: card-in 150ms ease-out;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
} }
.user-list { .user-list {
@@ -211,18 +342,18 @@ h2 {
button, .btn { button, .btn {
font: inherit; font: inherit;
background: var(--primary); background: var(--accent);
color: #fff; color: var(--bg);
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 600;
} }
button:hover, .btn:hover { button:hover, .btn:hover {
background: var(--primary-hover); filter: brightness(0.88);
text-decoration: none; text-decoration: none;
} }
@@ -233,7 +364,8 @@ button.secondary, .btn.secondary {
} }
button.secondary:hover, .btn.secondary:hover { button.secondary:hover, .btn.secondary:hover {
background: var(--bg); background: var(--surface-raised);
filter: none;
} }
table { table {
@@ -256,6 +388,11 @@ table th {
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
table td {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
table tr:last-child td { table tr:last-child td {
border-bottom: none; border-bottom: none;
} }
@@ -274,6 +411,8 @@ dl.info-grid dt {
dl.info-grid dd { dl.info-grid dd {
margin: 0; margin: 0;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
} }
.error { .error {
@@ -316,14 +455,20 @@ form.stacked-form label {
form.stacked-form input[type="text"], form.stacked-form input[type="text"],
form.stacked-form input[type="password"], form.stacked-form input[type="password"],
form.stacked-form input[type="email"] { form.stacked-form input[type="email"],
form.stacked-form input[type="number"] {
font: inherit; font: inherit;
color: var(--text);
padding: 0.5rem 0.65rem; padding: 0.5rem 0.65rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: var(--surface); background: var(--surface);
} }
form.stacked-form input[type="checkbox"] {
accent-color: var(--accent);
}
form.stacked-form button { form.stacked-form button {
margin-top: 1rem; margin-top: 1rem;
align-self: flex-start; align-self: flex-start;
@@ -332,3 +477,33 @@ form.stacked-form button {
.inline-form { .inline-form {
display: inline; display: inline;
} }
.sync-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
background: var(--surface-raised);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
}
.sync-status .next-sync {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
color: var(--accent);
text-transform: none;
}
.sync-status .next-sync::before {
content: "\25b8 ";
}

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

@@ -8,11 +8,14 @@
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16.png">
<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?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> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh &rarr; Garmin Sync</span> <span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh &rarr; Garmin Sync</span>
<div class="topbar-right">
<nav> <nav>
{% if request.session.get('admin_authenticated') %} {% if request.session.get('admin_authenticated') %}
<a href="/">Dashboard</a> <a href="/">Dashboard</a>
@@ -22,9 +25,15 @@
<a href="/account">My Account</a> <a href="/account">My Account</a>
{% endif %} {% endif %}
</nav> </nav>
{% set next_tick = next_sync_tick(request) %}
{% if next_tick %}
<span class="sync-status">Next sync: <time class="next-sync" datetime="{{ next_tick.isoformat() }}" data-utc="{{ next_tick.isoformat() }}">{{ next_tick.strftime('%Y-%m-%d %H:%M UTC') }}</time></span>
{% endif %}
</div>
</header> </header>
<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

@@ -0,0 +1,569 @@
# Visual Redesign ("Ride Computer" Dark Theme) 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:** Replace the app's light, generic look with a distinctive dark "bike computer cockpit" theme, and evolve the existing next-sync timestamp into a live ticking countdown, without changing any Python route, model, or template markup.
**Architecture:** A CSS design-token rewrite in `app/web/static/style.css` (color, typography, motion) that every existing template already picks up through shared classes (`.card`, `.badge`, `button`/`.btn`, `table`, `form.stacked-form`, `.sync-status`) — no template edits required. Separately, `app/web/static/app.js` gains a client-side countdown that ticks the already-rendered `time.next-sync[data-utc]` element down to zero, replacing the one-time UTC→local formatting it does today.
**Tech Stack:** Plain CSS custom properties, vanilla JS (no new dependencies, no build step — matches the existing project convention of zero frontend tooling).
**Spec:** `docs/superpowers/specs/2026-08-16-visual-redesign-design.md`
## Global Constraints
- No new dependencies, no build step — plain CSS and vanilla JS only (spec §2, §3).
- No route, model, or Python business-logic changes (spec §1).
- No template markup changes — every template already consumes the shared classes this plan restyles (spec §2, §6). `base.html`'s `.topbar-right` / `.sync-status` / `time.next-sync[data-utc]` structure from the prior next-sync feature is reused as-is.
- Dark theme only — no light-mode toggle (spec §2 "Out of scope").
- `data-utc` stays the server↔client contract; `tests/web/test_next_sync_display.py` must keep passing unmodified (spec §4 point 5).
- `base.html` needs **no edits** in this plan: the `.topbar-right` wrapper, the `.sync-status` span, and the `time.next-sync[data-utc]` element it needs already exist from the earlier next-sync feature. The spec's §6 rollout mentions `base.html` as a file touched by this work; in practice the existing markup already satisfies every hook Task 1's CSS and Task 2's JS need, so no template diff is required — this is a positive scope reduction, not a gap.
- CSS token value changes and the countdown's visual behavior have **no meaningful automated test** — the user explicitly chose manual/browser verification (via chrome-devtools screenshots) over introducing a JS test runner for this work. This is a deliberate, human-approved exception to normal TDD practice for these two tasks specifically; it does not extend to any future task that adds real branching logic without the user's sign-off.
---
### Task 1: Dark cockpit color, typography, and component tokens
**Files:**
- Modify: `app/web/static/style.css` (complete rewrite — every rule below)
- Test: none (pure CSS token values — see Global Constraints)
**Interfaces:**
- Consumes: nothing (leaf task, no code dependencies from other tasks).
- Produces: the `--bg`, `--surface`, `--surface-raised`, `--border`, `--text`, `--text-muted`, `--accent`, `--danger`, `--danger-bg`, `--success`, `--success-bg`, `--warning`, `--warning-bg`, `--neutral`, `--neutral-bg`, `--info`, `--info-bg`, `--radius`, `--shadow`, `--mono` custom properties and the `.sync-status` / `.sync-status .next-sync` selectors that Task 2's markup (already shipped in `base.html`) relies on for its visual presentation.
- [x] **Step 1: Replace `app/web/static/style.css` with the new token system and components**
Replace the entire file content with:
```css
:root {
--bg: #12151a;
--surface: #1a1f27;
--surface-raised: #232935;
--border: #2a3038;
--text: #e7eaf0;
--text-muted: #8b93a3;
--accent: #c8ff4d;
--danger: #ff5f6d;
--danger-bg: rgba(255, 95, 109, 0.16);
--success: #c8ff4d;
--success-bg: rgba(200, 255, 77, 0.16);
--warning: #ffb454;
--warning-bg: rgba(255, 180, 84, 0.16);
--neutral: #8b93a3;
--neutral-bg: rgba(139, 147, 163, 0.16);
--info: #5fd4ff;
--info-bg: rgba(95, 212, 255, 0.16);
--radius: 10px;
--shadow: none;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: var(--info);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:focus-visible,
button:focus-visible,
.btn:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.9rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar .brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
}
.brand-logo {
display: block;
border-radius: 7px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.topbar nav {
display: flex;
gap: 1.25rem;
font-size: 0.9rem;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1, h2 {
text-transform: uppercase;
letter-spacing: 0.06em;
}
h1 {
font-size: 1.4rem;
margin: 0 0 1.25rem;
}
h2 {
font-size: 1rem;
margin: 2rem 0 0.75rem;
color: var(--text-muted);
}
.page-actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem 1.4rem;
margin-bottom: 1rem;
animation: card-in 150ms ease-out;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
}
.user-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.user-card {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.25rem;
}
.user-card .user-main {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 220px;
}
.user-card .user-name {
font-weight: 600;
font-size: 1rem;
}
.user-card .user-meta {
color: var(--text-muted);
font-size: 0.85rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
}
.user-card .user-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.badge-healthy {
background: var(--success-bg);
color: var(--success);
}
.badge-syncing {
background: var(--info-bg);
color: var(--info);
}
.badge-degraded {
background: var(--warning-bg);
color: var(--warning);
}
.badge-action_required {
background: var(--danger-bg);
color: var(--danger);
}
.badge-disabled {
background: var(--neutral-bg);
color: var(--neutral);
}
.badge-success {
background: var(--success-bg);
color: var(--success);
}
.badge-partial {
background: var(--warning-bg);
color: var(--warning);
}
.badge-failed,
.badge-error {
background: var(--danger-bg);
color: var(--danger);
}
.badge-running {
background: var(--info-bg);
color: var(--info);
}
.action-required {
color: var(--danger);
font-size: 0.85rem;
}
button, .btn {
font: inherit;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 0.45rem 0.9rem;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
button:hover, .btn:hover {
filter: brightness(0.88);
text-decoration: none;
}
button.secondary, .btn.secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
button.secondary:hover, .btn.secondary:hover {
background: var(--surface-raised);
filter: none;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
table th, table td {
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
}
table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table td {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
table tr:last-child td {
border-bottom: none;
}
dl.info-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1.5rem;
margin: 0;
}
dl.info-grid dt {
color: var(--text-muted);
font-size: 0.85rem;
}
dl.info-grid dd {
margin: 0;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.error {
background: var(--danger-bg);
color: var(--danger);
padding: 0.6rem 0.9rem;
border-radius: 8px;
font-size: 0.9rem;
}
.hint {
color: var(--text-muted);
font-size: 0.8rem;
margin: -0.4rem 0 0.6rem;
}
.summary-error {
color: var(--danger);
font-size: 0.82rem;
}
.empty-state {
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
form.stacked-form {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 420px;
}
form.stacked-form label {
font-size: 0.85rem;
font-weight: 500;
margin-top: 0.6rem;
}
form.stacked-form input[type="text"],
form.stacked-form input[type="password"],
form.stacked-form input[type="email"] {
font: inherit;
color: var(--text);
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
form.stacked-form input[type="checkbox"] {
accent-color: var(--accent);
}
form.stacked-form button {
margin-top: 1rem;
align-self: flex-start;
}
.inline-form {
display: inline;
}
.sync-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
background: var(--surface-raised);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
}
.sync-status .next-sync {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
color: var(--accent);
text-transform: none;
}
.sync-status .next-sync::before {
content: "\25b8 ";
}
```
Notes on deliberate deviations from a literal reading of the spec, decided during implementation for consistency and to avoid template edits:
- `table td` and `dl.info-grid dd` get monospace/tabular-nums globally (not just "stats" cells) since every table and info-grid in this app is already timestamp/count/state data, and templates aren't being touched to add per-cell classes. Badges inherit this too (they sit inside `td`), which reads like an instrument-panel status readout rather than a problem.
- `--primary`/`--primary-hover` are removed (replaced by `--info` for links and `--accent` for buttons) since nothing in the templates references them directly (verified via grep — no inline `style="var(--...)"` usage anywhere).
- Button hover uses `filter: brightness(0.88)` instead of a second hardcoded accent hex, keeping the palette to the named tokens in the spec.
- [x] **Step 2: Run the full test suite to confirm no regressions**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as before this change (202 passed; the pre-existing unrelated `tests/mywhoosh/test_tokenstore.py::test_tokenstore_round_trip_and_permissions` failure on Windows is expected and untouched by this task).
- [x] **Step 3: Commit**
```bash
git add app/web/static/style.css
git commit -m "Redesign UI with dark cockpit color and typography tokens"
```
---
### Task 2: Live-ticking sync countdown
**Files:**
- Modify: `app/web/static/app.js` (complete rewrite)
- Test: none (see Global Constraints — user chose manual verification over introducing a JS test runner)
**Interfaces:**
- Consumes: the `time.next-sync[data-utc="<ISO-8601 instant>"]` element already rendered by `base.html` (from the previously shipped next-sync feature) and the `.sync-status` / `.sync-status .next-sync` CSS from Task 1.
- Produces: nothing consumed by a later task — this is the last code task.
- [x] **Step 1: Replace `app/web/static/app.js` with the countdown implementation**
```js
function formatCountdown(remainingMs) {
if (remainingMs <= 0) {
return "due now";
}
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n) => String(n).padStart(2, "0");
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
return `${pad(minutes)}:${pad(seconds)}`;
}
function startSyncCountdown(el) {
const target = new Date(el.dataset.utc);
if (Number.isNaN(target.getTime())) {
return;
}
el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`;
let intervalId = null;
const tick = () => {
const remaining = target.getTime() - Date.now();
el.textContent = formatCountdown(remaining);
if (remaining <= 0 && intervalId !== null) {
clearInterval(intervalId);
}
};
tick();
if (target.getTime() - Date.now() > 0) {
intervalId = setInterval(tick, 1000);
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown);
});
```
Why `intervalId` is declared with `let` before `tick` runs once synchronously: the first `tick()` call happens before `setInterval` returns, so if the target is already due on page load, `tick` must not call `clearInterval` on a not-yet-assigned `const` — that would throw a `ReferenceError`. Declaring `intervalId` as `let intervalId = null` up front and only scheduling the interval at all when the target is still in the future avoids the bug entirely (an already-due countdown just renders "due now" once and never starts ticking).
- [x] **Step 2: Manually verify in a real browser via chrome-devtools**
Start the app locally (same approach as the next-sync feature verification: temp SQLite DB, a valid `CREDENTIAL_ENCRYPTION_KEY` from `Fernet.generate_key()`, `ADMIN_PASSWORD`/`SECRET_KEY` set, `uvicorn app.main:create_app --factory`), then:
1. Navigate to `/login` and confirm the countdown ticks down every second (e.g. `04:58``04:57`).
2. Navigate to `/` (dashboard, after admin login) and confirm the same ticking countdown appears there too.
3. Edge case: temporarily set the fake/real scheduler's `next_tick` to a timestamp in the past (or wait past it) and reload — confirm the element shows `due now` instead of a negative or malformed countdown, and confirm no JS error appears in the DevTools console (`list_console_messages`).
4. Take a screenshot of the dashboard and the login page to visually confirm Task 1's dark theme and Task 2's countdown render together correctly.
Expected: countdown ticks live, "due now" renders cleanly for an already-past target, no console errors, screenshots show the dark cockpit theme with the lime countdown readout in the topbar.
- [x] **Step 3: Commit**
```bash
git add app/web/static/app.js
git commit -m "Turn the next-sync indicator into a live ticking countdown"
```
---
### Task 3: Final full-suite regression check
**Files:**
- None modified — verification only.
**Interfaces:**
- Consumes: the completed state of Task 1 and Task 2.
- Produces: nothing (terminal task).
- [x] **Step 1: Run the full Python test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: 202 passed, 1 pre-existing unrelated failure (`test_tokenstore_round_trip_and_permissions`, a Windows file-permission-bits issue predating this plan) — identical to the baseline recorded in Task 1 Step 2.
- [x] **Step 2: Report completion to the user**
Summarize what changed (dark cockpit theme across every page via shared CSS classes, live-ticking sync countdown) and point at the two screenshots taken in Task 2 Step 2 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

@@ -0,0 +1,157 @@
# Visual Redesign — "Ride Computer" Dark Theme — Design
Date: 2026-08-16
Status: Draft for user review
## 1. Goal
Replace the current light, generic admin-template look with a distinctive dark
"bike computer cockpit" theme grounded in the app's own subject matter (indoor
cycling sync, Garmin telemetry). The redesign is a shared foundation: it must
land before the planned dashboard summary tiles and live-update UX polish, so
those can be built directly in the new visual language instead of needing a
second pass.
This spec covers only the visual system and the already-shipped next-sync
indicator's evolution into a live countdown. It does not add new pages, new
data, or new business logic.
## 2. Scope
### In scope
- New CSS design-token system (color, spacing reuse, borders) replacing the
current light theme in `app/web/static/style.css`.
- Cockpit-style topbar (dark, brand mark, nav, live sync countdown).
- Restyled shared components: cards, buttons, badges, tables, forms, empty
states — applied globally via shared CSS classes so every existing template
(login, account-login, dashboard, account detail/edit, users
detail/new/edit, system, sync result fragment) picks it up without
structural rewrites.
- Typographic treatment: uppercase tracked labels for headings/section labels
(extends the existing table-header pattern), tabular monospace numerals for
all stats/timestamps/counters.
- Evolving the existing static next-sync timestamp (`app/web/static/app.js`,
`base.html`) into a live, client-ticking countdown.
- Accessible focus states (visible lime outline) and `prefers-reduced-motion`
handling for the one animated element (the countdown) and card entrance.
### Out of scope (separate follow-up specs)
- Dashboard summary/stat tiles (next phase, builds on this theme).
- Live sync results without full page reload (UX-polish phase).
- Any light-mode / theme-toggle support (explicitly rejected by user — dark
only).
- New charts/statistics views.
- Any change to routes, models, or sync/business logic.
## 3. Design tokens
### Color
| Token | Hex | Usage |
|---|---|---|
| `--bg` | `#12151A` | Page background |
| `--surface` | `#1A1F27` | Cards, topbar, table zebra-free rows |
| `--surface-raised` | `#232935` | Hover states, inputs |
| `--border` | `#2A3038` | Hairline dividers/card borders |
| `--text` | `#E7EAF0` | Primary text |
| `--text-muted` | `#8B93A3` | Secondary text, labels, hints |
| `--accent` | `#C8FF4D` | Electric lime — signature countdown, primary buttons, focus rings, "healthy" status |
Semantic status colors (each with a ~14% opacity tint of the same hue over
`--surface` for badge backgrounds, matching the existing `--*-bg` variable
pattern already in `style.css`):
| State | Hex | Meaning |
|---|---|---|
| Success / healthy | `--accent` `#C8FF4D` | Reuses the signature accent — a healthy sync *is* the good state the accent celebrates |
| Warning / degraded | `#FFB454` | Amber |
| Danger / action required / failed | `#FF5F6D` | Coral |
| Info / syncing / running | `#5FD4FF` | Cyan |
| Neutral / disabled | `#5A6472` | Slate |
These map 1:1 onto the existing `--success`, `--warning`, `--danger`,
`--info`, `--neutral` variable names already used by `.badge-*` classes in
`style.css` — only their values change, not the class structure, so templates
need no edits for badges.
### Typography
- Body/UI face: unchanged system stack (`-apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif`) — no new font loads,
works offline in a self-hosted container.
- Monospace face for all numeric data (stats, timestamps, the countdown,
table numeric columns): `ui-monospace, SFMono-Regular, Menlo, Consolas,
"Liberation Mono", monospace`, with `font-variant-numeric: tabular-nums`.
- Headings and structural labels (`h1`/`h2`, `.badge`, table `th`, the new
topbar labels): uppercase, `letter-spacing: 0.06em`, extending the tracked
uppercase style `table th` already has today — applied consistently instead
of only in tables.
### Layout
- Topbar becomes the cockpit header: `--surface` background, hairline bottom
border, brand mark left, nav + live countdown grouped right (existing
`.topbar-right` wrapper from the next-sync work is reused).
- Cards: hairline `--border` outline, `--surface` background, no drop shadow
(shadows read poorly on dark; hairlines carry the "device bezel" feel
instead). Radius stays at the existing `--radius: 10px`, unchanged.
- Buttons: primary uses `--accent` background with dark text (for contrast
against the light lime); secondary keeps outline/ghost style against
`--surface`.
- Focus-visible: 2px `--accent` outline on all interactive elements (links,
buttons, inputs) — dark backgrounds need this to stay accessible since the
current subtle browser default focus ring is hard to see on `--surface`.
### Motion
- The live countdown ticks once per second (text content update only, no
layout shift).
- Cards fade/slide in ~150ms on initial page load, `translateY(4px) → 0`.
- Both respect `prefers-reduced-motion: reduce` (countdown text still updates,
since it's informational, not decorative; the card entrance animation is
skipped entirely).
## 4. Signature element: live sync countdown
`app/web/static/app.js` currently does a one-time UTC→local conversion of the
`time[data-utc]` element on `DOMContentLoaded`. It's extended to:
1. On load, read `data-utc` as the target instant.
2. If the target is in the future, start a `setInterval` (1s) that computes
the remaining duration and renders it as `HH:MM:SS` (or `MM:SS` under an
hour) in monospace, e.g. `NEXT SYNC ▸ 00:12:04`.
3. If the target is in the past (page left open past the sync time, or
`next_tick` not yet known on first boot), render `due now` instead of a
negative countdown.
4. The `title` attribute keeps showing the absolute local time (via
`toLocaleString()`) and the original UTC instant, so hovering still gives
an absolute reference — this preserves today's behavior as a fallback/aid.
5. `data-utc` stays the templating contract between server and client (same
attribute the current tests assert on), so no server-side route or test
changes are needed for this evolution — only `app.js` behavior and the
surrounding CSS/markup in `base.html` change.
No server-side change: `next_sync_tick()` in `app/web/routes.py` and the
`base.html` template variable wiring stay as they are; only the visual
presentation and `app.js` ticking logic change.
## 5. Testing
This is a CSS/JS-presentation change with one markup adjustment (countdown
wrapper element/label in `base.html`). Existing server-rendered tests assert
on `data-utc="..."` substrings and text content, not on CSS classes or exact
visual output, so no test breakage is expected. No new automated test is
meaningful for pure CSS token values; the existing
`tests/web/test_next_sync_display.py` continues to guard the server-side
contract (the attribute and value), and manual verification (screenshot) is
used to confirm the visual outcome, consistent with how the next-sync feature
was verified.
## 6. Rollout
Single pass across `style.css`, `base.html`, `app.js`. No template
restructuring needed beyond `base.html`'s topbar, since all other templates
already consume the shared `.card`, `.badge`, `button`/`.btn`, `table`, and
`form.stacked-form` classes this spec restyles centrally.

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

@@ -0,0 +1,36 @@
from datetime import datetime, timezone
from fastapi.testclient import TestClient
class _FakeScheduler:
def __init__(self, next_tick=None) -> None:
self.last_tick = None
self.next_tick = next_tick
def test_login_page_shows_next_sync_time(app, client: TestClient) -> None:
next_tick = datetime(2026, 8, 16, 14, 32, tzinfo=timezone.utc)
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
response = client.get("/login")
assert response.status_code == 200
assert 'data-utc="2026-08-16T14:32:00+00:00"' in response.text
def test_dashboard_shows_next_sync_time(app, authenticated_client) -> None:
next_tick = datetime(2026, 8, 16, 15, 0, tzinfo=timezone.utc)
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
response = authenticated_client.get("/")
assert response.status_code == 200
assert 'data-utc="2026-08-16T15:00:00+00:00"' in response.text
def test_login_page_renders_without_scheduler(client: TestClient) -> None:
response = client.get("/login")
assert response.status_code == 200
assert "data-utc" not 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",

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)