Compare commits
15 Commits
722570c9d3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a23147dc8 | ||
|
|
450e4e935f | ||
|
|
13864a77f7 | ||
|
|
eb97374578 | ||
|
|
8d73dea7dd | ||
|
|
cc69b3ebb6 | ||
|
|
59b39f10cb | ||
|
|
f70f511907 | ||
|
|
a0890126bc | ||
|
|
9df6a619d2 | ||
|
|
a9a46e949f | ||
|
|
aa9e289185 | ||
|
|
b6d4be97c1 | ||
|
|
a74ab95f3f | ||
|
|
5d75aa328d |
@@ -1,13 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import (
|
||||
Activity,
|
||||
ActivityStatus,
|
||||
HealthState,
|
||||
SchedulerSettings,
|
||||
SyncRun,
|
||||
SyncRunStatus,
|
||||
@@ -19,6 +20,15 @@ from app.db.models import (
|
||||
_SCHEDULER_SETTINGS_ID = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardSummary:
|
||||
rider_total: int
|
||||
rider_enabled: int
|
||||
imported_recent: int
|
||||
success_rate_recent: float | None
|
||||
action_required_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserDashboardRow:
|
||||
id: int
|
||||
@@ -57,26 +67,64 @@ class UserRepository:
|
||||
return user
|
||||
|
||||
def dashboard_rows(self) -> list[UserDashboardRow]:
|
||||
users = self.list_all()
|
||||
rows = []
|
||||
for user in users:
|
||||
last_run = self.session.scalar(
|
||||
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
|
||||
return [self._build_dashboard_row(user) for user in self.list_all()]
|
||||
|
||||
def dashboard_row(self, user_id: int) -> UserDashboardRow | None:
|
||||
user = self.get(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
return self._build_dashboard_row(user)
|
||||
|
||||
def _build_dashboard_row(self, user: SyncUser) -> UserDashboardRow:
|
||||
last_run = self.session.scalar(
|
||||
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
|
||||
)
|
||||
last_activity = self.session.scalar(
|
||||
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
|
||||
)
|
||||
return UserDashboardRow(
|
||||
id=user.id,
|
||||
name=user.name,
|
||||
enabled=user.enabled,
|
||||
health_state=user.health_state.value,
|
||||
action_reason=user.action_reason,
|
||||
last_sync_at=last_run.finished_at if last_run else None,
|
||||
last_activity_name=last_activity.activity_name if last_activity else None,
|
||||
last_activity_status=last_activity.status.value if last_activity else None,
|
||||
)
|
||||
|
||||
def dashboard_summary(self, *, since: datetime) -> DashboardSummary:
|
||||
rider_total = self.session.scalar(select(func.count()).select_from(SyncUser)) or 0
|
||||
rider_enabled = self.session.scalar(
|
||||
select(func.count()).select_from(SyncUser).where(SyncUser.enabled.is_(True))
|
||||
) or 0
|
||||
action_required_count = self.session.scalar(
|
||||
select(func.count()).select_from(SyncUser).where(SyncUser.health_state == HealthState.ACTION_REQUIRED)
|
||||
) or 0
|
||||
imported_recent = self.session.scalar(
|
||||
select(func.coalesce(func.sum(SyncRun.imported_count), 0)).where(SyncRun.started_at >= since)
|
||||
) or 0
|
||||
|
||||
finished_runs = list(
|
||||
self.session.scalars(
|
||||
select(SyncRun).where(SyncRun.started_at >= since, SyncRun.finished_at.is_not(None))
|
||||
)
|
||||
last_activity = self.session.scalar(
|
||||
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
|
||||
)
|
||||
if finished_runs:
|
||||
successful = sum(
|
||||
1 for run in finished_runs if run.status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL)
|
||||
)
|
||||
rows.append(UserDashboardRow(
|
||||
id=user.id,
|
||||
name=user.name,
|
||||
enabled=user.enabled,
|
||||
health_state=user.health_state.value,
|
||||
action_reason=user.action_reason,
|
||||
last_sync_at=last_run.finished_at if last_run else None,
|
||||
last_activity_name=last_activity.activity_name if last_activity else None,
|
||||
last_activity_status=last_activity.status.value if last_activity else None,
|
||||
))
|
||||
return rows
|
||||
success_rate_recent = (successful / len(finished_runs)) * 100
|
||||
else:
|
||||
success_rate_recent = None
|
||||
|
||||
return DashboardSummary(
|
||||
rider_total=rider_total,
|
||||
rider_enabled=rider_enabled,
|
||||
imported_recent=imported_recent,
|
||||
success_rate_recent=success_rate_recent,
|
||||
action_required_count=action_required_count,
|
||||
)
|
||||
|
||||
|
||||
class ActivityRepository:
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.auth.csrf import ensure_csrf_token, validate_csrf
|
||||
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.sync.manager import SyncAlreadyRunning
|
||||
from app.web.operations import _normalize_outcome
|
||||
from app.web.operations import _outcome_toast, _toast_html
|
||||
from app.web.routes import templates
|
||||
|
||||
router = APIRouter()
|
||||
@@ -92,11 +92,19 @@ async def account_sync(request: Request, csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
try:
|
||||
outcome = await request.app.state.sync_manager.sync_user(user_id)
|
||||
with request.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
label = user.name if user is not None else "Account"
|
||||
message, level = _outcome_toast(outcome, label)
|
||||
except SyncAlreadyRunning:
|
||||
return HTMLResponse("Sync already running for this user", status_code=409)
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]}
|
||||
with request.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
label = user.name if user is not None else "Account"
|
||||
message, level = f"{label}: sync already running", "info"
|
||||
status_html = (
|
||||
templates.get_template("fragments/account_status.html").render(user=user) if user is not None else ""
|
||||
)
|
||||
return HTMLResponse(status_html + _toast_html(message, level))
|
||||
|
||||
|
||||
@router.get("/account/edit", response_class=HTMLResponse)
|
||||
|
||||
@@ -14,6 +14,16 @@ router = APIRouter()
|
||||
APP_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def _toast_html(message: str, level: str) -> str:
|
||||
return templates.get_template("fragments/toast.html").render(message=message, level=level)
|
||||
|
||||
|
||||
def _outcome_toast(outcome, label: str) -> tuple[str, str]:
|
||||
if outcome.status in ("success", "partial"):
|
||||
return f"{label}: {outcome.imported} imported, {outcome.failed} failed", "success"
|
||||
return f"{label}: sync failed — {outcome.message or 'unknown error'}", "danger"
|
||||
|
||||
|
||||
def _normalize_outcome(item):
|
||||
if isinstance(item, Exception):
|
||||
return {
|
||||
@@ -40,13 +50,24 @@ def _normalize_outcome(item):
|
||||
async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
token = ensure_csrf_token(request)
|
||||
try:
|
||||
outcome = await request.app.state.sync_manager.sync_user(user_id)
|
||||
with request.app.state.session_factory() as session:
|
||||
row = UserRepository(session).dashboard_row(user_id)
|
||||
label = row.name if row is not None else f"Rider #{user_id}"
|
||||
message, level = _outcome_toast(outcome, label)
|
||||
except SyncAlreadyRunning:
|
||||
return HTMLResponse("Sync already running for this user", status_code=409)
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]}
|
||||
with request.app.state.session_factory() as session:
|
||||
row = UserRepository(session).dashboard_row(user_id)
|
||||
label = row.name if row is not None else f"Rider #{user_id}"
|
||||
message, level = f"{label}: sync already running", "info"
|
||||
row_html = (
|
||||
templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=False)
|
||||
if row is not None
|
||||
else ""
|
||||
)
|
||||
return HTMLResponse(row_html + _toast_html(message, level))
|
||||
|
||||
|
||||
@router.post("/sync-all", response_class=HTMLResponse)
|
||||
@@ -54,9 +75,35 @@ async def manual_sync_all(request: Request, csrf_token: str = Form(...)):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
outcomes = await request.app.state.sync_manager.sync_all_enabled()
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(o) for o in outcomes]}
|
||||
)
|
||||
token = ensure_csrf_token(request)
|
||||
|
||||
parts = [templates.get_template("fragments/sync_all_form.html").render(csrf_token=token)]
|
||||
|
||||
ok = 0
|
||||
failed = 0
|
||||
with request.app.state.session_factory() as session:
|
||||
repository = UserRepository(session)
|
||||
for item in outcomes:
|
||||
normalized = _normalize_outcome(item)
|
||||
if normalized["status"] in ("success", "partial"):
|
||||
ok += 1
|
||||
else:
|
||||
failed += 1
|
||||
outcome_user_id = normalized["user_id"]
|
||||
if outcome_user_id is not None:
|
||||
row = repository.dashboard_row(outcome_user_id)
|
||||
if row is not None:
|
||||
parts.append(
|
||||
templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=True)
|
||||
)
|
||||
|
||||
if not outcomes:
|
||||
parts.append(_toast_html("No riders to sync", "info"))
|
||||
else:
|
||||
level = "success" if failed == 0 else "danger"
|
||||
parts.append(_toast_html(f"Synced {len(outcomes)} riders — {ok} ok, {failed} failed", level))
|
||||
|
||||
return HTMLResponse("".join(parts))
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/garmin-mfa", response_class=HTMLResponse)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
@@ -6,15 +8,41 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.auth.admin import password_matches, require_admin
|
||||
from app.auth.csrf import ensure_csrf_token, validate_csrf
|
||||
from app.db.models import SyncUser
|
||||
from app.db.models import SyncUser, utcnow
|
||||
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.web.forms import UserFormData
|
||||
|
||||
DASHBOARD_SUMMARY_WINDOW = timedelta(days=7)
|
||||
CACHE_BUSTED_STATIC_FILES = ("style.css", "app.js", "htmx.min.js")
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
|
||||
def _compute_static_version(static_dir: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
for name in CACHE_BUSTED_STATIC_FILES:
|
||||
try:
|
||||
hasher.update((static_dir / name).read_bytes())
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return hasher.hexdigest()[:10]
|
||||
|
||||
|
||||
templates.env.globals["static_version"] = _compute_static_version(
|
||||
Path(__file__).resolve().parent / "static"
|
||||
)
|
||||
|
||||
|
||||
def _next_sync_tick(request: Request):
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
return getattr(scheduler, "next_tick", None)
|
||||
|
||||
|
||||
templates.env.globals["next_sync_tick"] = _next_sync_tick
|
||||
|
||||
|
||||
def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser:
|
||||
user = repository.get(user_id)
|
||||
if user is None:
|
||||
@@ -62,11 +90,13 @@ def login(
|
||||
def dashboard(request: Request):
|
||||
require_admin(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
rows = UserRepository(session).dashboard_rows()
|
||||
repository = UserRepository(session)
|
||||
rows = repository.dashboard_rows()
|
||||
summary = repository.dashboard_summary(since=utcnow() - DASHBOARD_SUMMARY_WINDOW)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{"rows": rows, "csrf_token": ensure_csrf_token(request)},
|
||||
{"rows": rows, "summary": summary, "csrf_token": ensure_csrf_token(request)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
57
app/web/static/app.js
Normal file
57
app/web/static/app.js
Normal 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
1
app/web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,23 +1,24 @@
|
||||
:root {
|
||||
--bg: #f4f5f9;
|
||||
--surface: #ffffff;
|
||||
--border: #e3e6ee;
|
||||
--text: #1c2130;
|
||||
--text-muted: #6b7185;
|
||||
--primary: #4c5fd5;
|
||||
--primary-hover: #3d4dc0;
|
||||
--danger: #d64550;
|
||||
--danger-bg: #fdeceb;
|
||||
--success: #1f9d63;
|
||||
--success-bg: #e7f7ee;
|
||||
--warning: #b8860b;
|
||||
--warning-bg: #fdf3d9;
|
||||
--neutral: #6b7185;
|
||||
--neutral-bg: #eef0f5;
|
||||
--info: #2f7ec2;
|
||||
--info-bg: #e8f2fb;
|
||||
--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: 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 {
|
||||
color: var(--primary);
|
||||
color: var(--info);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,14 @@ 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);
|
||||
@@ -64,6 +73,13 @@ a:hover {
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar nav {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
@@ -76,14 +92,20 @@ a:hover {
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
margin: 2rem 0 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
@@ -94,6 +116,97 @@ h2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
|
||||
.stat-tile-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-tile-value {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-tile-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--text-muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
max-width: 320px;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.toast-danger {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
|
||||
.toast-leaving {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
button.htmx-request, .btn.htmx-request {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -101,6 +214,24 @@ h2 {
|
||||
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 {
|
||||
@@ -211,18 +342,18 @@ h2 {
|
||||
|
||||
button, .btn {
|
||||
font: inherit;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:hover, .btn:hover {
|
||||
background: var(--primary-hover);
|
||||
filter: brightness(0.88);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -233,7 +364,8 @@ button.secondary, .btn.secondary {
|
||||
}
|
||||
|
||||
button.secondary:hover, .btn.secondary:hover {
|
||||
background: var(--bg);
|
||||
background: var(--surface-raised);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -256,6 +388,11 @@ table th {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
table td {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -274,6 +411,8 @@ dl.info-grid dt {
|
||||
|
||||
dl.info-grid dd {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -316,14 +455,20 @@ form.stacked-form label {
|
||||
|
||||
form.stacked-form input[type="text"],
|
||||
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;
|
||||
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;
|
||||
@@ -332,3 +477,33 @@ form.stacked-form button {
|
||||
.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 ";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<h1>{{ user.name }}</h1>
|
||||
<div class="page-actions">
|
||||
<a class="btn secondary" href="/account/edit">Edit</a>
|
||||
<form method="post" action="/account/sync" class="inline-form">
|
||||
<form method="post" action="/account/sync" class="inline-form"
|
||||
hx-post="/account/sync" hx-target="#account-status" hx-swap="outerHTML" hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Sync now</button>
|
||||
</form>
|
||||
@@ -17,19 +18,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<dl class="info-grid">
|
||||
<dt>Status</dt>
|
||||
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
|
||||
|
||||
<dt>MyWhoosh state</dt>
|
||||
<dd>{{ user.mywhoosh_state }}</dd>
|
||||
|
||||
<dt>Garmin state</dt>
|
||||
<dd>{{ user.garmin_state }}</dd>
|
||||
|
||||
<dt>Action reason</dt>
|
||||
<dd>{{ user.action_reason or "-" }}</dd>
|
||||
</dl>
|
||||
{% include "fragments/account_status.html" %}
|
||||
</div>
|
||||
|
||||
{% if user.action_reason == "mywhoosh_device_conflict" %}
|
||||
|
||||
@@ -8,23 +8,32 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16.png">
|
||||
<link rel="shortcut icon" href="/static/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ static_version }}">
|
||||
<script src="/static/htmx.min.js?v={{ static_version }}" defer></script>
|
||||
<script src="/static/app.js?v={{ static_version }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh → Garmin Sync</span>
|
||||
<nav>
|
||||
{% if request.session.get('admin_authenticated') %}
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/system">System</a>
|
||||
<div class="topbar-right">
|
||||
<nav>
|
||||
{% if request.session.get('admin_authenticated') %}
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/system">System</a>
|
||||
{% endif %}
|
||||
{% if request.session.get('self_service_user_id') %}
|
||||
<a href="/account">My Account</a>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
{% if request.session.get('self_service_user_id') %}
|
||||
<a href="/account">My Account</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,38 +5,34 @@
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Riders</span>
|
||||
<span class="stat-tile-value">{{ summary.rider_total }}</span>
|
||||
<span class="stat-tile-sub">{{ summary.rider_enabled }} enabled</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Imported · 7d</span>
|
||||
<span class="stat-tile-value">{{ summary.imported_recent }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Success rate · 7d</span>
|
||||
<span class="stat-tile-value">{% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}–{% endif %}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Action required</span>
|
||||
<span class="stat-tile-value">{{ summary.action_required_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="btn secondary" href="/users/new">Add user</a>
|
||||
<form method="post" action="/sync-all" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Sync all now</button>
|
||||
</form>
|
||||
{% include "fragments/sync_all_form.html" %}
|
||||
</div>
|
||||
|
||||
<ul class="user-list">
|
||||
{% for row in rows %}
|
||||
<li class="card user-card">
|
||||
<div class="user-main">
|
||||
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
|
||||
<div class="user-meta">
|
||||
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
|
||||
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
|
||||
<span>last sync: {{ row.last_sync_at or "-" }}</span>
|
||||
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
|
||||
</div>
|
||||
{% if row.action_reason == "garmin_mfa_required" %}
|
||||
<span class="action-required">Garmin MFA required — <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 — log out there, then <a href="/users/{{ row.id }}">retry</a></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Sync now</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{% include "fragments/user_row.html" %}
|
||||
{% else %}
|
||||
<li class="card empty-state">No users yet.</li>
|
||||
{% endfor %}
|
||||
|
||||
13
app/web/templates/fragments/account_status.html
Normal file
13
app/web/templates/fragments/account_status.html
Normal 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>
|
||||
5
app/web/templates/fragments/sync_all_form.html
Normal file
5
app/web/templates/fragments/sync_all_form.html
Normal 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>
|
||||
3
app/web/templates/fragments/toast.html
Normal file
3
app/web/templates/fragments/toast.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div id="toast-container" class="toast-container" hx-swap-oob="true">
|
||||
<div class="toast toast-{{ level }}">{{ message }}</div>
|
||||
</div>
|
||||
24
app/web/templates/fragments/user_row.html
Normal file
24
app/web/templates/fragments/user_row.html
Normal 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 — <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 — 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>
|
||||
702
docs/superpowers/plans/2026-08-16-live-sync-updates.md
Normal file
702
docs/superpowers/plans/2026-08-16-live-sync-updates.md
Normal 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 — <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 — 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.
|
||||
569
docs/superpowers/plans/2026-08-16-visual-redesign.md
Normal file
569
docs/superpowers/plans/2026-08-16-visual-redesign.md
Normal 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.
|
||||
187
docs/superpowers/specs/2026-08-16-live-sync-updates-design.md
Normal file
187
docs/superpowers/specs/2026-08-16-live-sync-updates-design.md
Normal 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).
|
||||
157
docs/superpowers/specs/2026-08-16-visual-redesign-design.md
Normal file
157
docs/superpowers/specs/2026-08-16-visual-redesign-design.md
Normal 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.
|
||||
@@ -1,4 +1,6 @@
|
||||
from app.db.models import ActivityStatus, HealthState
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
|
||||
|
||||
|
||||
def test_create_two_independent_users(db_session, user_repository) -> None:
|
||||
@@ -129,3 +131,95 @@ def test_scheduler_settings_update_persists_all_fields(scheduler_settings_reposi
|
||||
assert reloaded.night_start_hour == 20
|
||||
assert reloaded.day_interval_minutes == 10
|
||||
assert reloaded.night_interval_minutes == 45
|
||||
|
||||
|
||||
def _make_user(repo, name, *, enabled=True, health_state=HealthState.HEALTHY):
|
||||
return repo.create(
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
health_state=health_state,
|
||||
mywhoosh_email_enc=f"mw-{name}",
|
||||
mywhoosh_password_enc=f"mw-pw-{name}",
|
||||
garmin_email_enc=f"g-{name}",
|
||||
garmin_password_enc=f"g-pw-{name}",
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_summary_counts_riders_total_and_enabled(user_repository) -> None:
|
||||
_make_user(user_repository, "Alex", enabled=True)
|
||||
_make_user(user_repository, "Jamie", enabled=True)
|
||||
_make_user(user_repository, "Paused", enabled=False)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
|
||||
|
||||
assert summary.rider_total == 3
|
||||
assert summary.rider_enabled == 2
|
||||
|
||||
|
||||
def test_dashboard_summary_counts_action_required_riders(user_repository) -> None:
|
||||
_make_user(user_repository, "Healthy", health_state=HealthState.HEALTHY)
|
||||
_make_user(user_repository, "Blocked", health_state=HealthState.ACTION_REQUIRED)
|
||||
_make_user(user_repository, "AlsoBlocked", health_state=HealthState.ACTION_REQUIRED)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
|
||||
|
||||
assert summary.action_required_count == 2
|
||||
|
||||
|
||||
def test_dashboard_summary_sums_imported_count_within_window_only(user_repository, sync_run_repository) -> None:
|
||||
user = _make_user(user_repository, "Alex")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
recent_run = sync_run_repository.start(user.id)
|
||||
recent_run.started_at = now - timedelta(days=1)
|
||||
sync_run_repository.finish(
|
||||
recent_run.id, status=SyncRunStatus.SUCCESS, discovered=5, imported=5, skipped=0, failed=0
|
||||
)
|
||||
|
||||
old_run = sync_run_repository.start(user.id)
|
||||
old_run.started_at = now - timedelta(days=30)
|
||||
sync_run_repository.finish(
|
||||
old_run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0
|
||||
)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
|
||||
|
||||
assert summary.imported_recent == 5
|
||||
|
||||
|
||||
def test_dashboard_summary_computes_success_rate_from_finished_runs_in_window(
|
||||
user_repository, sync_run_repository
|
||||
) -> None:
|
||||
user = _make_user(user_repository, "Alex")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL, SyncRunStatus.FAILED, SyncRunStatus.FAILED):
|
||||
run = sync_run_repository.start(user.id)
|
||||
run.started_at = now - timedelta(hours=1)
|
||||
sync_run_repository.finish(run.id, status=status, discovered=1, imported=0, skipped=0, failed=1)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
|
||||
|
||||
assert summary.success_rate_recent == 50.0
|
||||
|
||||
|
||||
def test_dashboard_summary_success_rate_is_none_without_finished_runs_in_window(user_repository) -> None:
|
||||
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
|
||||
|
||||
assert summary.success_rate_recent is None
|
||||
|
||||
|
||||
def test_dashboard_row_returns_row_for_known_user(user_repository) -> None:
|
||||
user = _make_user(user_repository, "Alex")
|
||||
|
||||
row = user_repository.dashboard_row(user.id)
|
||||
|
||||
assert row is not None
|
||||
assert row.id == user.id
|
||||
assert row.name == "Alex"
|
||||
|
||||
|
||||
def test_dashboard_row_returns_none_for_unknown_user(user_repository) -> None:
|
||||
row = user_repository.dashboard_row(999)
|
||||
|
||||
assert row is None
|
||||
|
||||
@@ -289,7 +289,7 @@ def test_account_sync_triggers_own_user_only(app, client: TestClient, fake_sync_
|
||||
assert fake_sync_manager.user_calls == [user_id]
|
||||
|
||||
|
||||
def test_account_sync_reports_already_running(app, client: TestClient, fake_sync_manager) -> None:
|
||||
def test_account_sync_reports_already_running_as_toast(app, client: TestClient, fake_sync_manager) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
@@ -299,10 +299,25 @@ def test_account_sync_reports_already_running(app, client: TestClient, fake_sync
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account/sync", data={"csrf_token": csrf})
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.status_code == 200
|
||||
assert "already running" in response.text.lower()
|
||||
|
||||
|
||||
def test_account_sync_updates_status_block_and_shows_toast(app, client: TestClient, fake_sync_manager) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
|
||||
page = client.get("/account")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account/sync", data={"csrf_token": csrf})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="account-status"' in response.text
|
||||
assert 'hx-swap-oob="true"' in response.text
|
||||
assert "0 imported, 0 failed" in response.text
|
||||
|
||||
|
||||
def test_account_sync_requires_login(client: TestClient) -> None:
|
||||
response = client.post("/account/sync", data={"csrf_token": "whatever"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
43
tests/web/test_dashboard_summary.py
Normal file
43
tests/web/test_dashboard_summary.py
Normal 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">–</span>' in response.text
|
||||
36
tests/web/test_next_sync_display.py
Normal file
36
tests/web/test_next_sync_display.py
Normal 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
|
||||
@@ -1,6 +1,20 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository
|
||||
from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository, UserRepository
|
||||
from app.sync.states import SyncOutcome
|
||||
|
||||
|
||||
def _create_user(app, name="Alex") -> int:
|
||||
with app.state.session_factory() as session:
|
||||
user = UserRepository(session).create(
|
||||
name=name,
|
||||
enabled=True,
|
||||
mywhoosh_email_enc="mw",
|
||||
mywhoosh_password_enc="mw-pw",
|
||||
garmin_email_enc="g",
|
||||
garmin_password_enc="g-pw",
|
||||
)
|
||||
return user.id
|
||||
|
||||
|
||||
def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
|
||||
@@ -12,13 +26,28 @@ def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manage
|
||||
assert fake_sync_manager.user_calls == [1]
|
||||
|
||||
|
||||
def test_manual_sync_reports_already_running(authenticated_client, fake_sync_manager) -> None:
|
||||
def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None:
|
||||
user_id = _create_user(app, "Alex")
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/users/{user_id}/sync",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'id="user-row-{user_id}"' in response.text
|
||||
assert 'hx-swap-oob="true"' in response.text
|
||||
assert "Alex" in response.text
|
||||
assert "0 imported, 0 failed" in response.text
|
||||
|
||||
|
||||
def test_manual_sync_reports_already_running_as_toast(authenticated_client, fake_sync_manager) -> None:
|
||||
fake_sync_manager.raise_already_running = True
|
||||
response = authenticated_client.post(
|
||||
"/users/1/sync",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.status_code == 200
|
||||
assert "already running" in response.text.lower()
|
||||
|
||||
|
||||
@@ -31,6 +60,34 @@ def test_sync_all_calls_shared_manager(authenticated_client, fake_sync_manager)
|
||||
assert fake_sync_manager.all_calls == 1
|
||||
|
||||
|
||||
def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None:
|
||||
user_id = _create_user(app, "Alex")
|
||||
|
||||
async def fake_sync_all_enabled():
|
||||
return [SyncOutcome(user_id=user_id, status="success", discovered=2, imported=2, skipped=0, failed=0)]
|
||||
|
||||
fake_sync_manager.sync_all_enabled = fake_sync_all_enabled
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/sync-all",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'id="user-row-{user_id}"' in response.text
|
||||
assert "Synced 1 riders" in response.text
|
||||
|
||||
|
||||
def test_sync_all_shows_toast_when_nothing_to_sync(authenticated_client, fake_sync_manager) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/sync-all",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No riders to sync" in response.text
|
||||
|
||||
|
||||
def test_manual_sync_requires_admin(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/users/1/sync",
|
||||
|
||||
12
tests/web/test_static_cache_busting.py
Normal file
12
tests/web/test_static_cache_busting.py
Normal 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)
|
||||
Reference in New Issue
Block a user