Compare commits

..

23 Commits

Author SHA1 Message Date
Bastian Wagner
8a23147dc8 Add cache-busting version query to static assets
Cloudflare was caching /static/style.css and /static/app.js at the
edge for up to 4 hours (its default Browser Cache TTL, since the app
sets no explicit Cache-Control), so deploys could look like they
hadn't landed even though the origin was already up to date. A
content hash of style.css/app.js/htmx.min.js, computed once at
startup, is now appended as ?v=<hash> to their URLs in base.html, so
every deploy that changes those files produces new, never-cached
URLs and needs no manual cache purge.

Also commits the live-sync-updates design spec, which was written
but never staged earlier in the session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:51:28 +02:00
Bastian Wagner
450e4e935f Mark live sync updates plan tasks complete
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:32:13 +02:00
Bastian Wagner
13864a77f7 Auto-dismiss sync toasts after a few seconds
Fixes two issues found during manual verification:
- fragments/toast.html was missing class="toast-container" on the
  swapped-in element, so the container lost its fixed-position
  styling after the first swap.
- htmx:oobAfterSwap's event.detail.target is the *old* element that
  just got replaced (outerHTML oob-swaps detach it), so the dismiss
  timer must look up the live #toast-container by id instead of
  trusting that stale reference.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:48:43 +02:00
Bastian Wagner
722570c9d3 icon 2026-08-15 21:51:53 +02:00
Bastian Wagner
ff5dab6f0d intervall 2026-08-15 21:44:13 +02:00
Bastian Wagner
f7b04337ce auth für user 2026-08-15 21:34:40 +02:00
Bastian Wagner
adfe14dfa8 logging 2026-08-15 21:05:55 +02:00
Bastian Wagner
420d089760 log 2026-08-15 20:54:35 +02:00
Bastian Wagner
2aba1265af mail notification 2026-08-15 20:47:02 +02:00
Bastian Wagner
7c9e19ba0b errors 2026-08-15 20:31:57 +02:00
Bastian Wagner
85b0d861b4 feat: restyle admin UI and add per-user sync run history
Adds a self-hosted stylesheet (no CDN dependencies) with a card-based
dashboard and color-coded status badges, and shows the last 10 sync
runs per user on the detail page.
2026-08-15 20:19:31 +02:00
63 changed files with 5018 additions and 180 deletions

View File

@@ -7,3 +7,12 @@ DATA_DIR=/data
DATABASE_URL=sqlite:////data/app.db
# Set to true only when TLS terminates in front of this service.
SESSION_HTTPS_ONLY=false
# Optional: SMTP settings for "email me when action is required" user
# notifications. Leave SMTP_HOST unset to disable sending (notifications are
# silently skipped rather than failing a sync run).
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=
SMTP_USE_TLS=true

36
app/auth/account.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlalchemy.orm import Session
from app.auth.admin import password_matches
from app.db.models import SyncUser
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def authenticate_self_service(
session: Session, cipher: CredentialCipher, email: str, password: str
) -> SyncUser | None:
"""Matches submitted email/password against any user's stored MyWhoosh OR
Garmin credentials -- decrypted and compared locally rather than
verified against the live MyWhoosh/Garmin APIs, so logging into this app
never opens a redundant upstream session (which, for MyWhoosh, would
itself trigger the "already logged in from another device" conflict)."""
submitted_email = email.strip()
if not submitted_email or not password:
return None
for user in UserRepository(session).list_all():
if _credential_matches(cipher, user.mywhoosh_email_enc, user.mywhoosh_password_enc, submitted_email, password):
return user
if _credential_matches(cipher, user.garmin_email_enc, user.garmin_password_enc, submitted_email, password):
return user
return None
def _credential_matches(
cipher: CredentialCipher, email_enc: str, password_enc: str, submitted_email: str, submitted_password: str
) -> bool:
try:
stored_email = cipher.decrypt(email_enc)
stored_password = cipher.decrypt(password_enc)
except ValueError:
return False
return stored_email == submitted_email and password_matches(submitted_password, stored_password)

View File

@@ -10,3 +10,10 @@ def password_matches(submitted: str, configured: str) -> bool:
def require_admin(request: Request) -> None:
if request.session.get("admin_authenticated") is not True:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
def require_self_service(request: Request) -> int:
user_id = request.session.get("self_service_user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
return user_id

View File

@@ -15,6 +15,12 @@ class Settings(BaseSettings):
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY")
smtp_host: str | None = Field(default=None, alias="SMTP_HOST")
smtp_port: int = Field(default=587, alias="SMTP_PORT")
smtp_username: str | None = Field(default=None, alias="SMTP_USERNAME")
smtp_password: str | None = Field(default=None, alias="SMTP_PASSWORD")
smtp_from_address: str | None = Field(default=None, alias="SMTP_FROM_ADDRESS")
smtp_use_tls: bool = Field(default=True, alias="SMTP_USE_TLS")
@model_validator(mode="after")
def derive_paths(self) -> "Settings":

View File

@@ -53,6 +53,8 @@ class SyncUser(Base):
mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
notify_email_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
notification_email: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -77,6 +79,30 @@ class Activity(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class SystemLogEntry(Base):
__tablename__ = "system_log_entries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
source: Mapped[str] = mapped_column(String(64), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL"))
class SchedulerSettings(Base):
"""Singleton row (id is always 1) holding the admin-configurable day/night
sync interval -- editable at runtime via the UI, unlike SYNC_INTERVAL_MINUTES
which only seeds this row's initial values on first startup."""
__tablename__ = "scheduler_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
day_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=6)
night_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
day_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
night_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
class SyncRun(Base):
__tablename__ = "sync_runs"

View File

@@ -1,11 +1,32 @@
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, SyncRun, SyncRunStatus, SyncUser, utcnow
from app.db.models import (
Activity,
ActivityStatus,
HealthState,
SchedulerSettings,
SyncRun,
SyncRunStatus,
SystemLogEntry,
SyncUser,
utcnow,
)
_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)
@@ -46,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:
@@ -191,6 +250,49 @@ class ActivityRepository:
)
class SystemLogRepository:
def __init__(self, session: Session) -> None:
self.session = session
def add(self, *, source: str, message: str, user_id: int | None = None) -> SystemLogEntry:
entry = SystemLogEntry(source=source, message=message[:2000], user_id=user_id)
self.session.add(entry)
self.session.commit()
return entry
def list_recent(self, limit: int = 50) -> list[SystemLogEntry]:
return list(
self.session.scalars(
select(SystemLogEntry)
.order_by(SystemLogEntry.created_at.desc(), SystemLogEntry.id.desc())
.limit(limit)
)
)
class SchedulerSettingsRepository:
def __init__(self, session: Session) -> None:
self.session = session
def get_or_create(self, *, default_minutes: int = 5) -> SchedulerSettings:
row = self.session.get(SchedulerSettings, _SCHEDULER_SETTINGS_ID)
if row is None:
row = SchedulerSettings(
id=_SCHEDULER_SETTINGS_ID,
day_interval_minutes=default_minutes,
night_interval_minutes=default_minutes,
)
self.session.add(row)
self.session.commit()
return row
def update(self, row: SchedulerSettings, **values) -> SchedulerSettings:
for key, value in values.items():
setattr(row, key, value)
self.session.commit()
return row
class SyncRunRepository:
def __init__(self, session: Session) -> None:
self.session = session
@@ -204,6 +306,16 @@ class SyncRunRepository:
def get(self, sync_run_id: int) -> SyncRun | None:
return self.session.get(SyncRun, sync_run_id)
def list_recent_for_user(self, user_id: int, limit: int = 10) -> list[SyncRun]:
return list(
self.session.scalars(
select(SyncRun)
.where(SyncRun.user_id == user_id)
.order_by(SyncRun.started_at.desc())
.limit(limit)
)
)
def finish(
self,
sync_run_id: int,

View File

@@ -1,9 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from app.db.models import Base
# Columns added to existing tables after their initial release. create_all()
# only creates missing tables, never adds columns to tables that already
# exist, so a column added to a model here must also be listed below or an
# already-deployed database will never receive it and the app will crash
# reading/writing that column.
_ADDITIVE_COLUMNS: dict[str, list[tuple[str, str]]] = {
"sync_users": [
("notify_email_enabled", "BOOLEAN NOT NULL DEFAULT 0"),
("notification_email", "VARCHAR(255)"),
],
}
def create_db_engine(database_url: str) -> Engine:
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -16,3 +28,24 @@ def create_session_factory(engine: Engine) -> sessionmaker[Session]:
def initialize_schema(engine: Engine) -> None:
Base.metadata.create_all(engine)
_apply_additive_migrations(engine)
def _apply_additive_migrations(engine: Engine) -> None:
if engine.dialect.name != "sqlite":
# ALTER TABLE ... ADD COLUMN syntax/type names below are only
# verified against sqlite, the only backend this app is deployed
# against; a fresh create_all() on another backend already has every
# current column, so skipping here only matters for a pre-existing
# non-sqlite database, which does not exist in practice.
return
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
with engine.begin() as conn:
for table, columns in _ADDITIVE_COLUMNS.items():
if table not in existing_tables:
continue
existing_columns = {col["name"] for col in inspector.get_columns(table)}
for name, ddl_type in columns:
if name not in existing_columns:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl_type}"))

View File

@@ -1,6 +1,8 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.config import Settings, get_settings
@@ -8,9 +10,11 @@ from app.db.session import create_db_engine, create_session_factory, initialize_
from app.fit.rewriter import convert_fit_device
from app.garmin.uploader import GarminUploader
from app.mywhoosh.client import MyWhooshClient
from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
from app.sync.scheduler import SyncScheduler
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler
from app.web.account import router as account_router
from app.web.operations import router as operations_router
from app.web.routes import router as web_router
@@ -31,6 +35,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
def garmin_factory(email, password, tokenstore):
return GarminUploader(email=email, password=password, tokenstore=tokenstore)
notifier = EmailNotifier(
host=resolved.smtp_host,
port=resolved.smtp_port,
username=resolved.smtp_username,
password=resolved.smtp_password,
from_address=resolved.smtp_from_address,
use_tls=resolved.smtp_use_tls,
)
sync_manager = SyncManager(
session_factory=app.state.session_factory,
credential_cipher=cipher,
@@ -38,10 +51,18 @@ def create_app(settings: Settings | None = None) -> FastAPI:
mywhoosh_factory=mywhoosh_factory,
garmin_factory=garmin_factory,
fit_converter=convert_fit_device,
notifier=notifier,
)
app.state.sync_manager = sync_manager
scheduler = SyncScheduler(sync_manager, interval_seconds=resolved.sync_interval_minutes * 60)
interval_provider = DayNightIntervalProvider(
app.state.session_factory, default_minutes=resolved.sync_interval_minutes
)
scheduler = SyncScheduler(
sync_manager,
interval_seconds=resolved.sync_interval_minutes * 60,
interval_provider=interval_provider,
)
app.state.scheduler = scheduler
await scheduler.start()
@@ -65,6 +86,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
app.include_router(web_router)
app.include_router(operations_router)
app.include_router(account_router)
app.mount(
"/static",
StaticFiles(directory=str(Path(__file__).resolve().parent / "web" / "static")),
name="static",
)
@app.get("/healthz")
def healthz() -> dict[str, str]:

View File

@@ -20,6 +20,10 @@ class MyWhooshAuthError(MyWhooshError):
pass
class MyWhooshDeviceConflictError(MyWhooshAuthError):
pass
class MyWhooshTransientError(MyWhooshError):
pass
@@ -52,7 +56,7 @@ class MyWhooshClient:
"Platform": "Android",
"Action": 1001,
"CorrelationId": str(uuid.uuid4()),
"DeviceId": str(uuid.uuid4()),
"DeviceId": self.token_store.get_or_create_device_id(),
"Authorization": "",
}
try:
@@ -70,7 +74,10 @@ class MyWhooshClient:
if not isinstance(body, dict):
raise MyWhooshIntegrationError("MyWhoosh login response is not a JSON object")
if body.get("Success") is not True or not body.get("AccessToken"):
raise MyWhooshAuthError(str(body.get("Message") or "MyWhoosh login failed"))
message = str(body.get("Message") or "MyWhoosh login failed")
if "another device" in message.lower():
raise MyWhooshDeviceConflictError(message)
raise MyWhooshAuthError(message)
self.token = MyWhooshToken(
access_token=str(body["AccessToken"]),
refresh_token=str(body["RefreshToken"]) if body.get("RefreshToken") else None,

View File

@@ -1,5 +1,6 @@
import json
import os
import uuid
from pathlib import Path
from app.mywhoosh.models import MyWhooshToken
@@ -8,6 +9,7 @@ from app.mywhoosh.models import MyWhooshToken
class MyWhooshTokenStore:
def __init__(self, path: Path) -> None:
self.path = path
self.device_id_path = path.with_name("device_id")
def load(self) -> MyWhooshToken | None:
try:
@@ -40,3 +42,16 @@ class MyWhooshTokenStore:
def clear(self) -> None:
self.path.unlink(missing_ok=True)
def get_or_create_device_id(self) -> str:
try:
existing = self.device_id_path.read_text("utf-8").strip()
except (FileNotFoundError, OSError):
existing = ""
if existing:
return existing
device_id = str(uuid.uuid4())
self.device_id_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.device_id_path.write_text(device_id, "utf-8")
os.chmod(self.device_id_path, 0o600)
return device_id

View File

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
class EmailNotifier:
def __init__(
self,
*,
host: str | None,
port: int,
username: str | None,
password: str | None,
from_address: str | None,
use_tls: bool,
) -> None:
self.host = host
self.port = port
self.username = username
self.password = password
self.from_address = from_address
self.use_tls = use_tls
@property
def configured(self) -> bool:
return bool(self.host and self.from_address)
def send(self, *, to_address: str, subject: str, body: str) -> None:
if not self.configured:
return
message = EmailMessage()
message["Subject"] = subject
message["From"] = self.from_address
message["To"] = to_address
message.set_content(body)
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
if self.use_tls:
smtp.starttls()
if self.username and self.password:
smtp.login(self.username, self.password)
smtp.send_message(message)

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from typing import Any, Callable
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository
from app.fit.rewriter import FitFormatError
from app.garmin.uploader import (
GarminAuthError,
@@ -14,7 +14,12 @@ from app.garmin.uploader import (
GarminTransientError,
GarminUploadBlocked,
)
from app.mywhoosh.client import MyWhooshAuthError, MyWhooshIntegrationError, MyWhooshTransientError
from app.mywhoosh.client import (
MyWhooshAuthError,
MyWhooshDeviceConflictError,
MyWhooshIntegrationError,
MyWhooshTransientError,
)
from app.mywhoosh.tokenstore import MyWhooshTokenStore
from app.security.credentials import CredentialCipher
from app.sync.states import SyncOutcome
@@ -30,7 +35,9 @@ class SyncAlreadyRunning(RuntimeError):
# succeeding (reaching the end of the activity loop with stop_user_run still
# False already proves MyWhoosh is working again -- every MyWhoosh-related
# exception branch that could fire also sets stop_user_run=True).
_MYWHOOSH_ACTION_REASONS = frozenset({"mywhoosh_auth_required", "mywhoosh_integration_changed"})
_MYWHOOSH_ACTION_REASONS = frozenset(
{"mywhoosh_auth_required", "mywhoosh_integration_changed", "mywhoosh_device_conflict"}
)
# action_reason values that can only be resolved by actual, this-run evidence
# of a successful Garmin import -- the mere absence of a Garmin exception does
@@ -57,6 +64,7 @@ class SyncManager:
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
garmin_factory: Callable[[str, str, Path], Any],
fit_converter: Callable[[Path, Path], Any],
notifier: Any = None,
) -> None:
self.session_factory = session_factory
self.credential_cipher = credential_cipher
@@ -64,9 +72,52 @@ class SyncManager:
self.mywhoosh_factory = mywhoosh_factory
self.garmin_factory = garmin_factory
self.fit_converter = fit_converter
self.notifier = notifier
self._locks: dict[int, asyncio.Lock] = {}
self._locks_guard = asyncio.Lock()
def _notify_action_required(self, session: Any, user: Any, message: str | None) -> None:
if self.notifier is None or not user.notify_email_enabled or not user.notification_email:
return
# A notifier with no SMTP host configured silently no-ops in send()
# rather than raising -- without this check, that silence would look
# identical to "everything's fine" in the system log, leaving an
# opted-in user with no way to find out why no mail ever arrives.
if not getattr(self.notifier, "configured", True):
self._record_log(
session,
(
f"Skipped action-required email to {user.notification_email} for user {user.id} "
f"({user.name}): SMTP is not configured (SMTP_HOST unset)."
),
user_id=user.id,
)
return
try:
self.notifier.send(
to_address=user.notification_email,
subject=f"MyWhoosh-Garmin Sync: action required for {user.name}",
body=message or "Your sync requires attention. Check the dashboard for details.",
)
except Exception as exc:
logger.warning(
"sync_user: failed to send action-required notification for user %s", user.id, exc_info=True
)
self._record_log(
session,
(
f"Failed to email {user.notification_email} for user {user.id} ({user.name}): "
f"{type(exc).__name__}: {exc}"
),
user_id=user.id,
)
def _record_log(self, session: Any, message: str, *, user_id: int | None = None) -> None:
try:
SystemLogRepository(session).add(source="email_notification", message=message, user_id=user_id)
except Exception:
logger.warning("sync_user: failed to record email-notification entry in system log", exc_info=True)
async def _lock_for(self, user_id: int) -> asyncio.Lock:
async with self._locks_guard:
return self._locks.setdefault(user_id, asyncio.Lock())
@@ -95,6 +146,12 @@ class SyncManager:
if user is None:
raise ValueError(f"user {user_id} not found")
# Captured before this run mutates action_reason, so the
# end-of-run notification below only fires on a transition into
# (or between) action-required states -- never repeatedly for a
# cause that's already been reported and still unresolved.
previous_action_reason = user.action_reason
sync_run_repo = SyncRunRepository(session)
run = sync_run_repo.start(user_id)
@@ -136,6 +193,14 @@ class SyncManager:
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except MyWhooshDeviceConflictError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "device_conflict"
user.action_reason = "mywhoosh_device_conflict"
session.commit()
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except MyWhooshAuthError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "auth_required"
@@ -245,6 +310,12 @@ class SyncManager:
user.mywhoosh_state = "error"
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
failed_count += 1
except MyWhooshDeviceConflictError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "device_conflict"
user.action_reason = "mywhoosh_device_conflict"
stop_user_run = True
summary_error = str(exc)
except MyWhooshAuthError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "auth_required"
@@ -345,6 +416,8 @@ class SyncManager:
summary_error=summary_error,
)
session.commit()
if user.action_reason is not None and user.action_reason != previous_action_reason:
self._notify_action_required(session, user, summary_error)
return SyncOutcome(
user_id=user.id,
status=status.value,

View File

@@ -1,22 +1,80 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Callable
from app.db.repositories import SchedulerSettingsRepository
from app.sync.manager import SyncAlreadyRunning
logger = logging.getLogger(__name__)
def is_daytime(hour: int, day_start_hour: int, night_start_hour: int) -> bool:
"""True if `hour` falls within [day_start_hour, night_start_hour),
handling a day period that wraps past midnight (e.g. day_start=20,
night_start=6). Equal start hours are treated as "always day"."""
if day_start_hour == night_start_hour:
return True
if day_start_hour < night_start_hour:
return day_start_hour <= hour < night_start_hour
return hour >= day_start_hour or hour < night_start_hour
class DayNightIntervalProvider:
"""Reads the admin-configurable day/night interval from the DB on every
call, so a change made via the UI takes effect on the scheduler's very
next tick without an app restart."""
def __init__(self, session_factory, *, default_minutes: int, now: Callable[[], datetime] | None = None) -> None:
self.session_factory = session_factory
self.default_minutes = default_minutes
self._now = now or datetime.now
def __call__(self) -> float:
with self.session_factory() as session:
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=self.default_minutes)
day_start_hour = row.day_start_hour
night_start_hour = row.night_start_hour
day_interval_minutes = row.day_interval_minutes
night_interval_minutes = row.night_interval_minutes
hour = self._now().hour
minutes = (
day_interval_minutes
if is_daytime(hour, day_start_hour, night_start_hour)
else night_interval_minutes
)
return minutes * 60
class SyncScheduler:
def __init__(self, manager, *, interval_seconds: float) -> None:
def __init__(
self,
manager,
*,
interval_seconds: float,
interval_provider: Callable[[], float] | None = None,
) -> None:
self.manager = manager
self.interval_seconds = interval_seconds
self.interval_provider = interval_provider
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self.last_tick = None
self.next_tick = None
async def run_once(self) -> None:
def _current_interval(self) -> float:
if self.interval_provider is None:
return self.interval_seconds
try:
return float(self.interval_provider())
except Exception:
logger.warning("interval_provider failed, falling back to default interval", exc_info=True)
return self.interval_seconds
async def run_once(self, interval: float | None = None) -> None:
if interval is None:
interval = self._current_interval()
self.last_tick = datetime.now(timezone.utc)
try:
results = await self.manager.sync_all_enabled()
@@ -36,13 +94,14 @@ class SyncScheduler:
except Exception:
logger.exception("sync_all_enabled failed during scheduled tick")
finally:
self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds)
self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=interval)
async def _run(self) -> None:
while not self._stop.is_set():
await self.run_once()
interval = self._current_interval()
await self.run_once(interval)
try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
await asyncio.wait_for(self._stop.wait(), timeout=interval)
except TimeoutError:
pass

161
app/web/account.py Normal file
View File

@@ -0,0 +1,161 @@
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.account import authenticate_self_service
from app.auth.admin import require_self_service
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 _outcome_toast, _toast_html
from app.web.routes import templates
router = APIRouter()
def _cipher(request: Request) -> CredentialCipher:
return CredentialCipher(request.app.state.settings.credential_encryption_key)
def _require_non_empty(value: str, field_name: str) -> None:
if not value.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name} must not be empty",
)
@router.get("/account-login", response_class=HTMLResponse)
def account_login_page(request: Request):
return templates.TemplateResponse(request, "account_login.html", {"csrf_token": ensure_csrf_token(request)})
@router.post("/account-login")
def account_login(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
password: str = Form(...),
):
validate_csrf(request, csrf_token)
cipher = _cipher(request)
with request.app.state.session_factory() as session:
user = authenticate_self_service(session, cipher, email, password)
if user is None:
return templates.TemplateResponse(
request,
"account_login.html",
{"csrf_token": ensure_csrf_token(request), "error": "Invalid email or password"},
status_code=401,
)
request.session["self_service_user_id"] = user.id
return RedirectResponse("/account", status_code=303)
@router.post("/account-logout")
def account_logout(request: Request, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
request.session.pop("self_service_user_id", None)
return RedirectResponse("/account-login", status_code=303)
def _get_own_user_or_reauth(request: Request, repository: UserRepository, user_id: int):
user = repository.get(user_id)
if user is None:
request.session.pop("self_service_user_id", None)
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
return user
@router.get("/account", response_class=HTMLResponse)
def account_detail(request: Request):
user_id = require_self_service(request)
with request.app.state.session_factory() as session:
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
activities = ActivityRepository(session).list_pending_for_user(user_id)
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
return templates.TemplateResponse(
request,
"account/detail.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"activities": activities,
"recent_runs": recent_runs,
},
)
@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))
@router.get("/account/edit", response_class=HTMLResponse)
def account_edit_page(request: Request):
user_id = require_self_service(request)
cipher = _cipher(request)
with request.app.state.session_factory() as session:
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
return templates.TemplateResponse(
request,
"account/form.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"mywhoosh_email": cipher.decrypt(user.mywhoosh_email_enc),
"garmin_email": cipher.decrypt(user.garmin_email_enc),
},
)
@router.post("/account/edit")
def account_update(
request: Request,
csrf_token: str = Form(...),
mywhoosh_email: str = Form(""),
mywhoosh_password: str = Form(""),
garmin_email: str = Form(""),
garmin_password: str = Form(""),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
):
user_id = require_self_service(request)
validate_csrf(request, csrf_token)
_require_non_empty(mywhoosh_email, "mywhoosh_email")
_require_non_empty(garmin_email, "garmin_email")
notify_enabled = notify_email_enabled is not None
if notify_enabled:
_require_non_empty(notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
user = _get_own_user_or_reauth(request, repository, user_id)
values = {
"mywhoosh_email_enc": cipher.encrypt(mywhoosh_email.strip()),
"garmin_email_enc": cipher.encrypt(garmin_email.strip()),
"notify_email_enabled": notify_enabled,
"notification_email": notification_email.strip() or None,
}
if mywhoosh_password:
values["mywhoosh_password_enc"] = cipher.encrypt(mywhoosh_password)
if garmin_password:
values["garmin_password_enc"] = cipher.encrypt(garmin_password)
repository.update(user, **values)
return RedirectResponse("/account", status_code=303)

View File

@@ -9,3 +9,5 @@ class UserFormData:
garmin_email: str
garmin_password: str
enabled: bool
notify_email_enabled: bool = False
notification_email: str = ""

View File

@@ -1,11 +1,11 @@
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func, select
from app.auth.admin import require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import Activity
from app.db.repositories import ActivityRepository, UserRepository
from app.db.repositories import ActivityRepository, SchedulerSettingsRepository, SystemLogRepository, UserRepository
from app.sync.manager import SyncAlreadyRunning
from app.web.routes import templates
@@ -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)
@@ -103,12 +150,46 @@ def system_page(request: Request):
with request.app.state.session_factory() as session:
user_count = len(UserRepository(session).list_all())
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
log_entries = SystemLogRepository(session).list_recent(limit=50)
scheduler_settings = SchedulerSettingsRepository(session).get_or_create(
default_minutes=settings.sync_interval_minutes
)
return templates.TemplateResponse(request, "system.html", {
"csrf_token": ensure_csrf_token(request),
"app_version": APP_VERSION,
"sync_interval_minutes": settings.sync_interval_minutes,
"last_tick": scheduler.last_tick,
"next_tick": scheduler.next_tick,
"user_count": user_count,
"activity_count": activity_count,
"log_entries": log_entries,
"scheduler_settings": scheduler_settings,
})
@router.post("/system/scheduler-settings")
def update_scheduler_settings(
request: Request,
csrf_token: str = Form(...),
day_start_hour: int = Form(...),
night_start_hour: int = Form(...),
day_interval_minutes: int = Form(...),
night_interval_minutes: int = Form(...),
):
require_admin(request)
validate_csrf(request, csrf_token)
if not (0 <= day_start_hour <= 23) or not (0 <= night_start_hour <= 23):
raise HTTPException(status_code=400, detail="Hours must be between 0 and 23")
if day_interval_minutes < 1 or night_interval_minutes < 1:
raise HTTPException(status_code=400, detail="Intervals must be at least 1 minute")
settings = request.app.state.settings
with request.app.state.session_factory() as session:
repository = SchedulerSettingsRepository(session)
row = repository.get_or_create(default_minutes=settings.sync_interval_minutes)
repository.update(
row,
day_start_hour=day_start_hour,
night_start_hour=night_start_hour,
day_interval_minutes=day_interval_minutes,
night_interval_minutes=night_interval_minutes,
)
return RedirectResponse("/system", status_code=303)

View File

@@ -1,3 +1,5 @@
import hashlib
from datetime import timedelta
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request, status
@@ -6,15 +8,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.repositories import ActivityRepository, UserRepository
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)},
)
@@ -96,6 +126,8 @@ def create_user(
garmin_email: str = Form(""),
garmin_password: str = Form(""),
enabled: str | None = Form(None),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
):
require_admin(request)
validate_csrf(request, csrf_token)
@@ -110,7 +142,11 @@ def create_user(
garmin_email=garmin_email,
garmin_password=garmin_password,
enabled=enabled is not None,
notify_email_enabled=notify_email_enabled is not None,
notification_email=notification_email,
)
if form.notify_email_enabled:
_require_non_empty(form.notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
@@ -121,6 +157,8 @@ def create_user(
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
garmin_password_enc=cipher.encrypt(form.garmin_password),
notify_email_enabled=form.notify_email_enabled,
notification_email=form.notification_email.strip() or None,
)
user_id = user.id
return RedirectResponse(f"/users/{user_id}", status_code=303)
@@ -132,6 +170,7 @@ def user_detail(request: Request, user_id: int):
with request.app.state.session_factory() as session:
user = _get_user_or_404(UserRepository(session), user_id)
activities = ActivityRepository(session).list_pending_for_user(user_id)
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
return templates.TemplateResponse(
request,
"users/detail.html",
@@ -139,6 +178,7 @@ def user_detail(request: Request, user_id: int):
"csrf_token": ensure_csrf_token(request),
"user": user,
"activities": activities,
"recent_runs": recent_runs,
},
)
@@ -173,6 +213,8 @@ def update_user(
garmin_email: str = Form(""),
garmin_password: str = Form(""),
enabled: str | None = Form(None),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
):
require_admin(request)
validate_csrf(request, csrf_token)
@@ -185,7 +227,11 @@ def update_user(
garmin_email=garmin_email,
garmin_password=garmin_password,
enabled=enabled is not None,
notify_email_enabled=notify_email_enabled is not None,
notification_email=notification_email,
)
if form.notify_email_enabled:
_require_non_empty(form.notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
@@ -195,6 +241,8 @@ def update_user(
"enabled": form.enabled,
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
"garmin_email_enc": cipher.encrypt(form.garmin_email.strip()),
"notify_email_enabled": form.notify_email_enabled,
"notification_email": form.notification_email.strip() or None,
}
if form.mywhoosh_password:
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)

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

@@ -0,0 +1,57 @@
function formatCountdown(remainingMs) {
if (remainingMs <= 0) {
return "due now";
}
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n) => String(n).padStart(2, "0");
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
return `${pad(minutes)}:${pad(seconds)}`;
}
function startSyncCountdown(el) {
const target = new Date(el.dataset.utc);
if (Number.isNaN(target.getTime())) {
return;
}
el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`;
let intervalId = null;
const tick = () => {
const remaining = target.getTime() - Date.now();
el.textContent = formatCountdown(remaining);
if (remaining <= 0 && intervalId !== null) {
clearInterval(intervalId);
}
};
tick();
if (target.getTime() - Date.now() > 0) {
intervalId = setInterval(tick, 1000);
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown);
});
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
if (event.detail.target.id !== "toast-container") {
return;
}
// event.detail.target is the *old* element htmx just swapped out (an
// outerHTML oob-swap detaches it), so look the live one up by id
// rather than trusting that reference.
const container = document.getElementById("toast-container");
const toast = container ? container.querySelector(".toast") : null;
if (!toast) {
return;
}
setTimeout(() => {
toast.classList.add("toast-leaving");
setTimeout(() => toast.remove(), 300);
}, 4000);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
app/web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

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

File diff suppressed because one or more lines are too long

BIN
app/web/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

509
app/web/static/style.css Normal file
View File

@@ -0,0 +1,509 @@
: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;
}
.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);
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"],
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;
}
.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 ";
}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}My Account - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<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"
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>
<form method="post" action="/account-logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Log out</button>
</form>
</div>
<div class="card">
{% include "fragments/account_status.html" %}
</div>
{% if user.action_reason == "mywhoosh_device_conflict" %}
<h2>MyWhoosh device conflict</h2>
<div class="card">
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
</div>
{% endif %}
<h2>Recent sync runs</h2>
<div class="card">
{% if recent_runs %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Finished</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for run in recent_runs %}
<tr>
<td>{{ run.started_at }}</td>
<td>{{ run.finished_at or "-" }}</td>
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
<td>{{ run.discovered_count }}</td>
<td>{{ run.imported_count }}</td>
<td>{{ run.skipped_count }}</td>
<td>{{ run.failed_count }}</td>
</tr>
{% if run.summary_error %}
<tr>
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No sync runs yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Edit My Account - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Edit My Account</h1>
<div class="card">
<form method="post" action="/account/edit" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="mywhoosh_email">MyWhoosh Email</label>
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
<label for="mywhoosh_password">MyWhoosh Password</label>
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password">
<p class="hint">Leave blank to keep the existing password.</p>
<label for="garmin_email">Garmin Email</label>
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
<label for="garmin_password">Garmin Password</label>
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password">
<p class="hint">Leave blank to keep the existing password.</p>
<label for="notify_email_enabled">
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
{% if user.notify_email_enabled %}checked{% endif %}>
Email me when this account needs attention
</label>
<label for="notification_email">Notification email</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ user.notification_email or '' }}">
<button type="submit">Save</button>
</form>
</div>
<p><a href="/account">Back to my account</a></p>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Account Login - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Account Login</h1>
<div class="card">
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/account-login" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="email">MyWhoosh or Garmin email</label>
<input type="email" id="email" name="email" required autofocus>
<label for="password">MyWhoosh or Garmin password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Log in</button>
</form>
<p class="hint">Use the email and password for either your MyWhoosh or your Garmin account.</p>
</div>
<p><a href="/login">Admin login</a></p>
{% endblock %}

View File

@@ -2,9 +2,38 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MyWhoosh Garmin Sync{% endblock %}</title>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
<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?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>
{% block content %}{% endblock %}
<header class="topbar">
<span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh &rarr; Garmin Sync</span>
<div class="topbar-right">
<nav>
{% 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 %}
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<div id="toast-container" class="toast-container"></div>
</body>
</html>

View File

@@ -4,31 +4,37 @@
{% block content %}
<h1>Dashboard</h1>
<p><a href="/users/new">Add user</a> | <a href="/system">System</a></p>
<form method="post" action="/sync-all">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
<div class="stat-tiles">
<div class="stat-tile">
<span class="stat-tile-label">Riders</span>
<span class="stat-tile-value">{{ summary.rider_total }}</span>
<span class="stat-tile-sub">{{ summary.rider_enabled }} enabled</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Imported &middot; 7d</span>
<span class="stat-tile-value">{{ summary.imported_recent }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Success rate &middot; 7d</span>
<span class="stat-tile-value">{% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}&ndash;{% endif %}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Action required</span>
<span class="stat-tile-value">{{ summary.action_required_count }}</span>
</div>
</div>
<ul>
<div class="page-actions">
<a class="btn secondary" href="/users/new">Add user</a>
{% include "fragments/sync_all_form.html" %}
</div>
<ul class="user-list">
{% for row in rows %}
<li>
<a href="/users/{{ row.id }}">{{ row.name }}</a>
&mdash; {{ "enabled" if row.enabled else "disabled" }}
&mdash; {{ row.health_state }}
&mdash; last sync: {{ row.last_sync_at or "-" }}
&mdash; last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required — <a href="/users/{{ row.id }}">resolve</a></span>
{% endif %}
<form method="post" action="/users/{{ row.id }}/sync" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button>
</form>
</li>
{% include "fragments/user_row.html" %}
{% else %}
<li>No users yet.</li>
<li class="card empty-state">No users yet.</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,13 @@
<dl class="info-grid" id="account-status">
<dt>Status</dt>
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
</dl>

View File

@@ -1,5 +1,6 @@
<form method="post" action="/users/{{ user.id }}/garmin-mfa">
<form method="post" action="/users/{{ user.id }}/garmin-mfa" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Garmin MFA code: <input type="text" name="code" maxlength="20" required></label>
<label for="mfa_code">Garmin MFA code</label>
<input type="text" id="mfa_code" name="code" maxlength="20" required>
<button type="submit">Submit code</button>
</form>

View File

@@ -0,0 +1,5 @@
<form method="post" action="/sync-all" class="inline-form"
hx-post="/sync-all" hx-target="this" hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>

View File

@@ -1,17 +1,45 @@
<ul>
{% for outcome in outcomes %}
<li>
User {{ outcome.user_id if outcome.user_id is not none else "unknown" }}:
status={{ outcome.status }}
discovered={{ outcome.discovered }}
imported={{ outcome.imported }}
skipped={{ outcome.skipped }}
failed={{ outcome.failed }}
{% if outcome.message %}
&mdash; {{ outcome.message }}
{% endif %}
</li>
{% else %}
<li>No outcomes.</li>
{% endfor %}
</ul>
{% extends "base.html" %}
{% block title %}Sync result - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Sync result</h1>
<div class="page-actions">
<a class="btn secondary" href="/">Back to dashboard</a>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>User</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for outcome in outcomes %}
<tr>
<td>{{ outcome.user_id if outcome.user_id is not none else "unknown" }}</td>
<td><span class="badge badge-{{ outcome.status }}">{{ outcome.status }}</span></td>
<td>{{ outcome.discovered }}</td>
<td>{{ outcome.imported }}</td>
<td>{{ outcome.skipped }}</td>
<td>{{ outcome.failed }}</td>
</tr>
{% if outcome.message %}
<tr>
<td colspan="6" class="summary-error">{{ outcome.message }}</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan="6" class="empty-state">No outcomes.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

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

View File

@@ -0,0 +1,24 @@
<li class="card user-card" id="user-row-{{ row.id }}"{% if oob %} hx-swap-oob="true"{% endif %}>
<div class="user-main">
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
<div class="user-meta">
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
<span>last sync: {{ row.last_sync_at or "-" }}</span>
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
</div>
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required &mdash; <a href="/users/{{ row.id }}">resolve</a></span>
{% elif row.action_reason == "mywhoosh_device_conflict" %}
<span class="action-required">MyWhoosh account logged in on another device &mdash; log out there, then <a href="/users/{{ row.id }}">retry</a></span>
{% endif %}
</div>
<div class="user-actions">
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form"
hx-post="/users/{{ row.id }}/sync" hx-target="closest .user-card" hx-swap="outerHTML"
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Sync now</button>
</form>
</div>
</li>

View File

@@ -4,13 +4,16 @@
{% block content %}
<h1>Admin Login</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
<button type="submit">Log in</button>
</form>
<div class="card">
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
<button type="submit">Log in</button>
</form>
</div>
<p><a href="/account-login">Log in with your MyWhoosh or Garmin account instead</a></p>
{% endblock %}

View File

@@ -4,30 +4,79 @@
{% block content %}
<h1>System</h1>
<p><a href="/">Back to dashboard</a></p>
<dl>
<dt>Application version</dt>
<dd>{{ app_version }}</dd>
<div class="card">
<dl class="info-grid">
<dt>Application version</dt>
<dd>{{ app_version }}</dd>
<dt>Sync interval (minutes)</dt>
<dd>{{ sync_interval_minutes }}</dd>
<dt>Last scheduler tick</dt>
<dd>{{ last_tick or "-" }}</dd>
<dt>Last scheduler tick</dt>
<dd>{{ last_tick or "-" }}</dd>
<dt>Next scheduler tick</dt>
<dd>{{ next_tick or "-" }}</dd>
<dt>Next scheduler tick</dt>
<dd>{{ next_tick or "-" }}</dd>
<dt>User count</dt>
<dd>{{ user_count }}</dd>
<dt>User count</dt>
<dd>{{ user_count }}</dd>
<dt>Activity count</dt>
<dd>{{ activity_count }}</dd>
</dl>
</div>
<dt>Activity count</dt>
<dd>{{ activity_count }}</dd>
</dl>
<form method="post" action="/sync-all">
<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>
<h2>Sync scheduling</h2>
<div class="card">
<form method="post" action="/system/scheduler-settings" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="day_start_hour">Day starts at (hour, 0-23)</label>
<input type="number" id="day_start_hour" name="day_start_hour" min="0" max="23"
value="{{ scheduler_settings.day_start_hour }}" required>
<label for="day_interval_minutes">Sync interval during the day (minutes)</label>
<input type="number" id="day_interval_minutes" name="day_interval_minutes" min="1"
value="{{ scheduler_settings.day_interval_minutes }}" required>
<label for="night_start_hour">Night starts at (hour, 0-23)</label>
<input type="number" id="night_start_hour" name="night_start_hour" min="0" max="23"
value="{{ scheduler_settings.night_start_hour }}" required>
<label for="night_interval_minutes">Sync interval during the night (minutes)</label>
<input type="number" id="night_interval_minutes" name="night_interval_minutes" min="1"
value="{{ scheduler_settings.night_interval_minutes }}" required>
<button type="submit">Save</button>
</form>
</div>
<h2>System log</h2>
<div class="card">
{% if log_entries %}
<table>
<thead>
<tr>
<th>Time</th>
<th>Source</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for entry in log_entries %}
<tr>
<td>{{ entry.created_at }}</td>
<td>{{ entry.source }}</td>
<td>{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No system log entries.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -4,50 +4,103 @@
{% block content %}
<h1>{{ user.name }}</h1>
<p><a href="/users/{{ user.id }}/edit">Edit</a> | <a href="/">Back to dashboard</a></p>
<div class="page-actions">
<a class="btn secondary" href="/users/{{ user.id }}/edit">Edit</a>
<a class="btn secondary" href="/">Back to dashboard</a>
</div>
<dl>
<dt>Enabled</dt>
<dd>{{ "Yes" if user.enabled else "No" }}</dd>
<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>Health state</dt>
<dd>{{ user.health_state.value }}</dd>
<dt>Enabled</dt>
<dd>{{ "Yes" if user.enabled else "No" }}</dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
<dt>Created at</dt>
<dd>{{ user.created_at }}</dd>
<dt>Created at</dt>
<dd>{{ user.created_at }}</dd>
<dt>Updated at</dt>
<dd>{{ user.updated_at }}</dd>
</dl>
<dt>Updated at</dt>
<dd>{{ user.updated_at }}</dd>
</dl>
</div>
{% if user.action_reason == "garmin_mfa_required" %}
<h2>Garmin MFA required</h2>
{% include "fragments/mfa_form.html" %}
<div class="card">
{% include "fragments/mfa_form.html" %}
</div>
{% elif user.action_reason == "mywhoosh_device_conflict" %}
<h2>MyWhoosh device conflict</h2>
<div class="card">
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
</div>
{% endif %}
<h2>Activities</h2>
<ul>
{% for activity in activities %}
<li>
{{ activity.activity_name }} &mdash; {{ activity.status.value }}
{% if activity.status.value == "failed" and activity.retryable %}
<form method="post" action="/activities/{{ activity.id }}/retry" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Retry</button>
</form>
{% endif %}
</li>
<h2>Recent sync runs</h2>
<div class="card">
{% if recent_runs %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Finished</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for run in recent_runs %}
<tr>
<td>{{ run.started_at }}</td>
<td>{{ run.finished_at or "-" }}</td>
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
<td>{{ run.discovered_count }}</td>
<td>{{ run.imported_count }}</td>
<td>{{ run.skipped_count }}</td>
<td>{{ run.failed_count }}</td>
</tr>
{% if run.summary_error %}
<tr>
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<li>No pending activities.</li>
{% endfor %}
</ul>
<p class="empty-state">No sync runs yet.</p>
{% endif %}
</div>
<h2>Activities</h2>
<div class="card">
<ul class="user-list">
{% for activity in activities %}
<li>
{{ activity.activity_name }} &mdash; {{ activity.status.value }}
{% if activity.status.value == "failed" and activity.retryable %}
<form method="post" action="/activities/{{ activity.id }}/retry" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Retry</button>
</form>
{% endif %}
</li>
{% else %}
<li class="empty-state">No pending activities.</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -4,37 +4,49 @@
{% block content %}
<h1>{% if user %}Edit User{% else %}New User{% endif %}</h1>
<form method="post" action="{{ form_action }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="card">
<form method="post" action="{{ form_action }}" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ user.name if user else '' }}" required>
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ user.name if user else '' }}" required>
<label for="mywhoosh_email">MyWhoosh Email</label>
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
<label for="mywhoosh_email">MyWhoosh Email</label>
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
<label for="mywhoosh_password">MyWhoosh Password</label>
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="mywhoosh_password">MyWhoosh Password</label>
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="garmin_email">Garmin Email</label>
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
<label for="garmin_email">Garmin Email</label>
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
<label for="garmin_password">Garmin Password</label>
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="garmin_password">Garmin Password</label>
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="enabled">
<input type="checkbox" id="enabled" name="enabled" {% if not user or user.enabled %}checked{% endif %}>
Enabled
</label>
<label for="enabled">
<input type="checkbox" id="enabled" name="enabled" {% if not user or user.enabled %}checked{% endif %}>
Enabled
</label>
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
</form>
<label for="notify_email_enabled">
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
{% if user and user.notify_email_enabled %}checked{% endif %}>
Email me when this account needs attention
</label>
<label for="notification_email">Notification email</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ user.notification_email if user and user.notification_email else '' }}">
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
</form>
</div>
{% endblock %}

BIN
bike.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,702 @@
# Live Sync Updates (HTMX) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** "Sync now" / "Sync all now" on the dashboard, and "Sync now" on the account page, update the affected rider row(s) or status block in place and show an auto-dismissing toast, instead of navigating to a separate result page.
**Architecture:** Vendor htmx (self-hosted) as the swap mechanism. Extract the dashboard row and the account status block into reusable Jinja partials so the same markup renders both the initial page and the post-sync response. Routes always return their own primary target's current state plus zero-or-more out-of-band updates plus exactly one out-of-band toast, so every swap is safe even on a no-op path.
**Tech Stack:** htmx v2.0.10 (vendored static file, no build step), plain CSS, a small addition to the existing vanilla `app.js`.
**Spec:** `docs/superpowers/specs/2026-08-16-live-sync-updates-design.md`
## Global Constraints
- No new Python dependencies; htmx is a single vendored static JS file (spec §3).
- `fragments/sync_result.html`, the activity retry route, and the Garmin MFA route are untouched — out of scope (spec §2).
- Every htmx POST route returns its own primary swap target's current state (never empty) plus exactly one toast; `/sync-all` additionally returns one OOB row per outcome with a resolvable `user_id` (spec §3).
- `POST /users/{id}/sync` and `POST /account/sync` return HTTP 200 for the "already running" case now (previously 409) — existing tests for that behavior must be updated to match, per spec §2.
- htmx's own swap/toast behavior has no meaningful Python-level test; it is verified manually via chrome-devtools, matching how the next-sync countdown was verified (spec §4).
---
### Task 1: `UserRepository.dashboard_row`
**Files:**
- Modify: `app/db/repositories.py`
- Test: `tests/db/test_repositories.py`
**Interfaces:**
- Produces: `UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`, used by Task 4 and Task 5's routes.
- [x] **Step 1: Write the failing tests**
Add to `tests/db/test_repositories.py`:
```python
def test_dashboard_row_returns_row_for_known_user(user_repository) -> None:
user = _make_user(user_repository, "Alex")
row = user_repository.dashboard_row(user.id)
assert row is not None
assert row.id == user.id
assert row.name == "Alex"
def test_dashboard_row_returns_none_for_unknown_user(user_repository) -> None:
row = user_repository.dashboard_row(999)
assert row is None
```
- [x] **Step 2: Run to verify failure**
Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -k dashboard_row -v`
Expected: both FAIL with `AttributeError: 'UserRepository' object has no attribute 'dashboard_row'`.
- [x] **Step 3: Extract the shared row builder and add `dashboard_row`**
In `app/db/repositories.py`, replace the body of `dashboard_rows` with a call to a new private helper, and add `dashboard_row`:
```python
def dashboard_rows(self) -> list[UserDashboardRow]:
return [self._build_dashboard_row(user) for user in self.list_all()]
def dashboard_row(self, user_id: int) -> UserDashboardRow | None:
user = self.get(user_id)
if user is None:
return None
return self._build_dashboard_row(user)
def _build_dashboard_row(self, user: SyncUser) -> UserDashboardRow:
last_run = self.session.scalar(
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
)
last_activity = self.session.scalar(
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
)
return UserDashboardRow(
id=user.id,
name=user.name,
enabled=user.enabled,
health_state=user.health_state.value,
action_reason=user.action_reason,
last_sync_at=last_run.finished_at if last_run else None,
last_activity_name=last_activity.activity_name if last_activity else None,
last_activity_status=last_activity.status.value if last_activity else None,
)
```
This is a pure refactor of the existing `dashboard_rows` loop body — behavior for `dashboard_rows()` itself must not change.
- [x] **Step 4: Run to verify pass**
Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -v`
Expected: all tests pass, including the two new ones and the existing `dashboard_rows`-adjacent coverage (none currently exists directly, but nothing regresses).
- [x] **Step 5: Commit**
```bash
git add app/db/repositories.py tests/db/test_repositories.py
git commit -m "Add UserRepository.dashboard_row for single-row refresh"
```
---
### Task 2: Vendor htmx and wire up base.html + toast/loading CSS
**Files:**
- Create: `app/web/static/htmx.min.js` (vendored, v2.0.10)
- Modify: `app/web/templates/base.html`
- Modify: `app/web/static/style.css`
- Test: none (static asset + markup/CSS; full suite re-run at the end of this task to confirm no regressions)
**Interfaces:**
- Produces: the `#toast-container` element and `.toast`/`.toast-success`/`.toast-danger`/`.toast-info` classes that Task 3 and Task 5's `fragments/toast.html` renders into; the `.htmx-request` dimming rule.
- [x] **Step 1: Vendor htmx**
Download `https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js` and save it verbatim as `app/web/static/htmx.min.js` (already fetched once this session — reuse that content; if re-fetching, confirm the response is the same v2.0.10 minified build before saving).
- [x] **Step 2: Load htmx and add the toast container in `base.html`**
```html
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<header class="topbar">
```
(only the new `htmx.min.js` line is added — `app.js` stays second so htmx's global is present before app.js's own listeners are registered, though with `defer` both run in document order regardless of load timing)
And right before `</body>`:
```html
<div id="toast-container" class="toast-container"></div>
</body>
```
- [x] **Step 3: Add toast and htmx-request CSS to `style.css`**
```css
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--surface-raised);
border: 1px solid var(--border);
border-left: 3px solid var(--text-muted);
border-radius: 8px;
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
color: var(--text);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-width: 320px;
transition: opacity 200ms ease, transform 200ms ease;
}
.toast-success {
border-left-color: var(--success);
}
.toast-danger {
border-left-color: var(--danger);
}
.toast-info {
border-left-color: var(--info);
}
.toast-leaving {
opacity: 0;
transform: translateX(8px);
}
@media (prefers-reduced-motion: reduce) {
.toast {
transition: none;
}
}
button.htmx-request, .btn.htmx-request {
opacity: 0.6;
cursor: progress;
}
```
- [x] **Step 4: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as the Task 1 baseline (no route/template behavior changed yet — only a new unused static file, an unreferenced-so-far toast container, and new CSS rules).
- [x] **Step 5: Commit**
```bash
git add app/web/static/htmx.min.js app/web/templates/base.html app/web/static/style.css
git commit -m "Vendor htmx and add toast/loading-state CSS"
```
---
### Task 3: Dashboard row and sync-all-form partials
**Files:**
- Create: `app/web/templates/fragments/user_row.html`
- Create: `app/web/templates/fragments/sync_all_form.html`
- Create: `app/web/templates/fragments/toast.html`
- Modify: `app/web/templates/dashboard.html`
- Test: `tests/web/test_dashboard_summary.py` (existing tests must keep passing — they assert on stat-tile markup, not row markup, but re-run to confirm)
**Interfaces:**
- Consumes: `UserDashboardRow` fields (spec §3), `csrf_token`.
- Produces: `fragments/user_row.html` renders one `<li id="user-row-<id>">`, accepting `row`, `csrf_token`, `oob` (default `False`) — Task 4's route renders this same template standalone. `fragments/toast.html` accepts `message`, `level` — Task 4 and Task 5 both render it standalone.
- [x] **Step 1: Create `fragments/user_row.html`**
```html
<li class="card user-card" id="user-row-{{ row.id }}"{% if oob %} hx-swap-oob="true"{% endif %}>
<div class="user-main">
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
<div class="user-meta">
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
<span>last sync: {{ row.last_sync_at or "-" }}</span>
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
</div>
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required &mdash; <a href="/users/{{ row.id }}">resolve</a></span>
{% elif row.action_reason == "mywhoosh_device_conflict" %}
<span class="action-required">MyWhoosh account logged in on another device &mdash; log out there, then <a href="/users/{{ row.id }}">retry</a></span>
{% endif %}
</div>
<div class="user-actions">
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form"
hx-post="/users/{{ row.id }}/sync" hx-target="closest .user-card" hx-swap="outerHTML"
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Sync now</button>
</form>
</div>
</li>
```
- [x] **Step 2: Create `fragments/sync_all_form.html`**
```html
<form method="post" action="/sync-all" class="inline-form"
hx-post="/sync-all" hx-target="this" hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
```
- [x] **Step 3: Create `fragments/toast.html`**
```html
<div id="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>
```
- [x] **Step 4: Update `dashboard.html` to use the partials**
Replace the `<form ... action="/sync-all" ...>` block inside `.page-actions` with:
```html
{% include "fragments/sync_all_form.html" %}
```
Replace the `<li class="card user-card">...</li>` block inside the `{% for row in rows %}` loop with:
```html
{% include "fragments/user_row.html" %}
```
(the `{% else %}No users yet.{% endif %}` branch is unchanged)
- [x] **Step 5: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as Task 2's baseline — this is a pure template refactor (the `{% include %}` inherits `row`/`csrf_token` from the enclosing loop/page context automatically), so no existing assertion on dashboard content should break. If `tests/web/test_dashboard_summary.py` or any dashboard test fails, stop and inspect — it means the include isn't inheriting context as expected.
- [x] **Step 6: Commit**
```bash
git add app/web/templates/fragments/user_row.html app/web/templates/fragments/sync_all_form.html app/web/templates/fragments/toast.html app/web/templates/dashboard.html
git commit -m "Extract dashboard row and sync-all form into reusable partials"
```
---
### Task 4: Live-update the dashboard sync routes
**Files:**
- Modify: `app/web/operations.py`
- Test: `tests/web/test_operations.py`
**Interfaces:**
- Consumes: `UserRepository.dashboard_row` (Task 1), `fragments/user_row.html` / `fragments/sync_all_form.html` / `fragments/toast.html` (Task 3).
- Produces: `_toast_html(message: str, level: str) -> str` and `_outcome_toast(outcome, label: str) -> tuple[str, str]`, imported by Task 5's `app/web/account.py`.
- [x] **Step 1: Write the failing tests**
Replace the existing `test_manual_sync_reports_already_running` in `tests/web/test_operations.py` (the 409 behavior is intentionally removed — see Global Constraints) and add new coverage:
```python
def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None:
with app.state.session_factory() as session:
from app.db.repositories import UserRepository
user = UserRepository(session).create(
name="Alex",
enabled=True,
mywhoosh_email_enc="mw",
mywhoosh_password_enc="mw-pw",
garmin_email_enc="g",
garmin_password_enc="g-pw",
)
user_id = user.id
response = authenticated_client.post(
f"/users/{user_id}/sync",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert f'id="user-row-{user_id}"' in response.text
assert 'hx-swap-oob="true"' in response.text # the toast
assert "Alex" in response.text
assert "0 imported, 0 failed" in response.text
def test_manual_sync_reports_already_running_as_toast(authenticated_client, fake_sync_manager) -> None:
fake_sync_manager.raise_already_running = True
response = authenticated_client.post(
"/users/1/sync",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert "already running" in response.text.lower()
def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None:
from app.sync.states import SyncOutcome
with app.state.session_factory() as session:
from app.db.repositories import UserRepository
user = UserRepository(session).create(
name="Alex",
enabled=True,
mywhoosh_email_enc="mw",
mywhoosh_password_enc="mw-pw",
garmin_email_enc="g",
garmin_password_enc="g-pw",
)
user_id = user.id
async def fake_sync_all_enabled():
return [SyncOutcome(user_id=user_id, status="success", discovered=2, imported=2, skipped=0, failed=0)]
fake_sync_manager.sync_all_enabled = fake_sync_all_enabled
response = authenticated_client.post(
"/sync-all",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert f'id="user-row-{user_id}"' in response.text
assert "Synced 1 riders" in response.text
def test_sync_all_shows_toast_when_nothing_to_sync(authenticated_client, fake_sync_manager) -> None:
response = authenticated_client.post(
"/sync-all",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert "No riders to sync" in response.text
```
Remove the old `test_manual_sync_reports_already_running` test (it asserted `status_code == 409`, which this task intentionally changes).
- [x] **Step 2: Run to verify failure**
Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v`
Expected: the four new/changed tests FAIL (old route still returns `fragments/sync_result.html` and a 409 for already-running); other existing tests in the file still pass unchanged.
- [x] **Step 3: Rewrite the routes**
In `app/web/operations.py`, add two module-level helpers near the top (after `_normalize_outcome`):
```python
def _toast_html(message: str, level: str) -> str:
return templates.get_template("fragments/toast.html").render(message=message, level=level)
def _outcome_toast(outcome, label: str) -> tuple[str, str]:
if outcome.status in ("success", "partial"):
return f"{label}: {outcome.imported} imported, {outcome.failed} failed", "success"
return f"{label}: sync failed — {outcome.message or 'unknown error'}", "danger"
```
Replace `manual_sync`:
```python
@router.post("/users/{user_id}/sync", response_class=HTMLResponse)
async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
token = ensure_csrf_token(request)
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
with request.app.state.session_factory() as session:
row = UserRepository(session).dashboard_row(user_id)
label = row.name if row is not None else f"Rider #{user_id}"
message, level = _outcome_toast(outcome, label)
except SyncAlreadyRunning:
with request.app.state.session_factory() as session:
row = UserRepository(session).dashboard_row(user_id)
label = row.name if row is not None else f"Rider #{user_id}"
message, level = f"{label}: sync already running", "info"
row_html = templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=False) if row else ""
return HTMLResponse(row_html + _toast_html(message, level))
```
Replace `manual_sync_all`:
```python
@router.post("/sync-all", response_class=HTMLResponse)
async def manual_sync_all(request: Request, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
outcomes = await request.app.state.sync_manager.sync_all_enabled()
token = ensure_csrf_token(request)
parts = [templates.get_template("fragments/sync_all_form.html").render(csrf_token=token)]
ok = 0
failed = 0
with request.app.state.session_factory() as session:
repository = UserRepository(session)
for item in outcomes:
normalized = _normalize_outcome(item)
if normalized["status"] in ("success", "partial"):
ok += 1
else:
failed += 1
user_id = normalized["user_id"]
if user_id is not None:
row = repository.dashboard_row(user_id)
if row is not None:
parts.append(
templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=True)
)
if not outcomes:
parts.append(_toast_html("No riders to sync", "info"))
else:
level = "success" if failed == 0 else "danger"
parts.append(_toast_html(f"Synced {len(outcomes)} riders — {ok} ok, {failed} failed", level))
return HTMLResponse("".join(parts))
```
Update the two remaining imports at the top of `app/web/operations.py`: `UserRepository` is already imported; `ensure_csrf_token` is already imported alongside `validate_csrf`.
- [x] **Step 4: Run to verify pass**
Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v`
Expected: all pass.
- [x] **Step 5: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as Task 3's baseline plus the net new tests in this task, minus the one removed 409 test (net +3 tests). No unrelated regressions.
- [x] **Step 6: Commit**
```bash
git add app/web/operations.py tests/web/test_operations.py
git commit -m "Live-update dashboard rows and show toasts after sync actions"
```
---
### Task 5: Live-update the account page sync route
**Files:**
- Create: `app/web/templates/fragments/account_status.html`
- Modify: `app/web/templates/account/detail.html`
- Modify: `app/web/account.py`
- Test: `tests/web/test_account_web.py`
**Interfaces:**
- Consumes: `_toast_html`, `_outcome_toast` (Task 4, imported from `app.web.operations`).
- Produces: nothing consumed by a later task.
- [x] **Step 1: Create `fragments/account_status.html`**
```html
<dl class="info-grid" id="account-status">
<dt>Status</dt>
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
</dl>
```
- [x] **Step 2: Update `account/detail.html`**
Replace the `<div class="card"><dl class="info-grid">...</dl></div>` block with:
```html
<div class="card">
{% include "fragments/account_status.html" %}
</div>
```
Replace the "Sync now" form in `.page-actions`:
```html
<form method="post" action="/account/sync" class="inline-form"
hx-post="/account/sync" hx-target="#account-status" hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button>
</form>
```
- [x] **Step 3: Write the failing tests**
Update `test_account_sync_reports_already_running` in `tests/web/test_account_web.py`:
```python
def test_account_sync_reports_already_running_as_toast(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
fake_sync_manager.raise_already_running = True
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert "already running" in response.text.lower()
```
Add:
```python
def test_account_sync_updates_status_block_and_shows_toast(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert 'id="account-status"' in response.text
assert 'hx-swap-oob="true"' in response.text
assert "0 imported, 0 failed" in response.text
```
- [x] **Step 4: Run to verify failure**
Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v`
Expected: the new/changed tests FAIL (route still returns `fragments/sync_result.html` / 409).
- [x] **Step 5: Rewrite `account_sync` in `app/web/account.py`**
```python
from app.web.operations import _normalize_outcome, _outcome_toast, _toast_html
```
(add `_outcome_toast, _toast_html` to the existing import line from `app.web.operations`)
```python
@router.post("/account/sync", response_class=HTMLResponse)
async def account_sync(request: Request, csrf_token: str = Form(...)):
user_id = require_self_service(request)
validate_csrf(request, csrf_token)
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
with request.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
label = user.name if user is not None else "Account"
message, level = _outcome_toast(outcome, label)
except SyncAlreadyRunning:
with request.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
label = user.name if user is not None else "Account"
message, level = f"{label}: sync already running", "info"
status_html = (
templates.get_template("fragments/account_status.html").render(user=user) if user is not None else ""
)
return HTMLResponse(status_html + _toast_html(message, level))
```
- [x] **Step 6: Run to verify pass**
Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v`
Expected: all pass.
- [x] **Step 7: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as Task 4's baseline plus this task's net new tests, no unrelated regressions.
- [x] **Step 8: Commit**
```bash
git add app/web/templates/fragments/account_status.html app/web/templates/account/detail.html app/web/account.py tests/web/test_account_web.py
git commit -m "Live-update the account page status block after sync"
```
---
### Task 6: Toast auto-dismiss and manual browser verification
**Files:**
- Modify: `app/web/static/app.js`
- Test: none (see Global Constraints — manual verification)
**Interfaces:**
- Consumes: the `#toast-container` element (Task 2) and the `hx-swap-oob` toast fragments (Task 4, Task 5) that htmx swaps into it, firing its `htmx:oobAfterSwap` event.
- [x] **Step 1: Add the auto-dismiss listener to `app.js`**
Append to `app/web/static/app.js` (after the existing `DOMContentLoaded` listener, as a new top-level statement):
```js
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
if (event.detail.target.id !== "toast-container") {
return;
}
const toast = event.detail.target.querySelector(".toast");
if (!toast) {
return;
}
setTimeout(() => {
toast.classList.add("toast-leaving");
setTimeout(() => toast.remove(), 300);
}, 4000);
});
```
- [x] **Step 2: Manually verify in a real browser via chrome-devtools**
Start the app locally (same approach as prior manual verifications). Log in as admin, add a rider, then:
1. Click "Sync now" on the rider's row — confirm the row updates in place (no navigation, URL stays `/`), a toast appears top-right, and it fades out and disappears after ~4 seconds.
2. Click "Sync all now" — confirm the button re-renders, the row updates, and a summary toast appears.
3. Log in via `/account-login` as the same rider, click "Sync now" on the account page — confirm the status block updates in place and a toast appears.
4. Check the DevTools console (`list_console_messages`) for errors after each of the above.
5. Take a screenshot showing a toast visible on the dashboard.
Expected: no full-page navigation for any of the three actions, no console errors, toast appears and later disappears.
- [x] **Step 3: Commit**
```bash
git add app/web/static/app.js
git commit -m "Auto-dismiss sync toasts after a few seconds"
```
---
### Task 7: Final full-suite regression check
**Files:**
- None modified — verification only.
- [x] **Step 1: Run the full Python test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: baseline pass count (from before this plan) plus this plan's net new/changed tests, with the same single pre-existing unrelated Windows file-permission failure (`test_tokenstore_round_trip_and_permissions`) and nothing else.
- [x] **Step 2: Report completion to the user**
Summarize what changed and point at the Task 6 Step 2 screenshot as evidence.

View File

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

View File

@@ -0,0 +1,187 @@
# Live Sync Updates (HTMX) — Design
Date: 2026-08-16
Status: Draft for user review
## 1. Goal
Replace the full-page navigation that currently happens after clicking
"Sync now" / "Sync all now" with an in-place update: the affected rider
row(s) refresh with their new status, and a short toast reports the
outcome — without leaving the dashboard or account page.
## 2. Scope
### In scope
- Vendoring htmx (v2.0.10, self-hosted, no CDN) as the swap mechanism.
- Dashboard: per-rider "Sync now" and "Sync all now".
- Account page (self-service): "Sync now".
- A toast notification system (one at a time, auto-dismissing) built on
htmx out-of-band swaps.
- Removing the HTTP 409 special case for "sync already running" — it
becomes a normal toast instead of a distinct error page/status.
### Out of scope (unchanged in this pass)
- Activity retry button (`/activities/{id}/retry`) — still navigates to
the old `fragments/sync_result.html` page.
- Garmin MFA form (`/users/{id}/garmin-mfa`) — still navigates to the old
page; MFA failure often needs a fresh code anyway, so the extra step is
less costly there.
- Live-updating the "Recent sync runs" table on the account/user detail
pages — a completed sync's new row only appears after the next full
page load.
- Any change to `SyncManager`, `SyncOutcome`, or scheduler behavior.
## 3. Architecture
### htmx
`app/web/static/htmx.min.js` (vendored, v2.0.10) is loaded in `base.html`
via `<script src="/static/htmx.min.js" defer></script>`, alongside the
existing `app.js`.
### The "always return current state" rule
Every htmx-driven POST route in scope returns two things in one response
body:
1. The current, freshly-reloaded state of its own primary swap target
(even on a no-op path like "sync already running", or on the
`/sync-all` form itself, which always re-renders unchanged). This
makes every swap safe/idempotent — the target is never replaced with
nothing.
2. Exactly one out-of-band toast fragment (`fragments/toast.html`,
`hx-swap-oob="true"` on `#toast-container`) describing what happened.
`/sync-all` additionally emits one out-of-band row update
(`fragments/user_row.html` rendered with `oob=True`) per rider whose
outcome carries a known `user_id` — riders unaffected by that run (e.g.
disabled) are left alone.
### Shared row partial
`app/web/templates/fragments/user_row.html` renders one
`<li class="card user-card" id="user-row-{{ row.id }}">...</li>`, taking
`row` (a `UserDashboardRow`), `csrf_token`, and `oob` (default `False`,
adds `hx-swap-oob="true"` to the root element when `True`). `dashboard.html`
`{% include %}`s it once per row in its existing loop (`oob` omitted,
defaults to `False`) instead of inlining the `<li>` markup — this is the
only change to the existing loop, so the initial page render is
byte-for-byte equivalent to today's markup plus the new `hx-*` attributes
on the row and its form.
### Shared account status partial
`app/web/templates/fragments/account_status.html` renders the
`<dl class="info-grid" id="account-status">...</dl>` block (Status,
MyWhoosh state, Garmin state, Action reason) that today lives inline in
`account/detail.html`. Same include pattern.
### New repository method
`UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`
in `app/db/repositories.py` — the existing `dashboard_rows()` loop body is
extracted into a private `_build_dashboard_row(user: SyncUser) -> UserDashboardRow`
helper that both `dashboard_rows()` and the new `dashboard_row(user_id)`
call, so there is exactly one place that assembles a row.
### Route changes
`app/web/operations.py`:
- `manual_sync` (`POST /users/{user_id}/sync`): on success, on
`SyncAlreadyRunning`, and on any other outcome, always ends by opening a
fresh session, calling `UserRepository(session).dashboard_row(user_id)`,
and rendering `fragments/user_row.html` (`oob=False`, since this row IS
the primary `hx-target`) followed by a toast whose message/level depend
on the outcome. Always returns HTTP 200 now (no more 409).
- `manual_sync_all` (`POST /sync-all`): re-renders the trigering `<form>`
unchanged as the primary swap content (`fragments/sync_all_form.html`,
a two-line partial holding just that form), then one
`fragments/user_row.html` (`oob=True`) per outcome with a resolvable
`user_id`, then one summary toast, e.g. `"Synced 3 riders — 2 ok, 1
failed"` or `"No riders to sync"` when the outcome list is empty.
`app/web/account.py`:
- `account_sync` (`POST /account/sync`): same "always return current
state + toast" shape, but the primary target is
`fragments/account_status.html` re-rendered from the freshly reloaded
`SyncUser`, not a row.
### Toast levels and copy
| Situation | Level | Message |
|---|---|---|
| `status in (success, partial)` | success | `"<name>: <imported> imported, <failed> failed"` |
| `status == failed` | danger | `"<name>: sync failed — <message or 'unknown error'>"` |
| `SyncAlreadyRunning` caught | info | `"<name>: sync already running"` |
| Exception (unexpected) | danger | `"<name>: sync error — <message>"` |
| `/sync-all` summary | success if all ok else danger | `"Synced <n> riders — <ok> ok, <failed> failed"` |
| `/sync-all` with zero enabled riders | info | `"No riders to sync"` |
`fragments/toast.html` takes `message: str` and `level: Literal["success",
"danger", "info"]`, rendering:
```html
<div id="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>
```
`base.html` gets an empty `<div id="toast-container" class="toast-container"></div>`
right before `</body>` so the very first toast has something to swap.
### Auto-dismiss
`app/web/static/app.js` gains an `htmx:oobAfterSwap` listener: when the
swapped element's id is `toast-container`, it schedules the `.toast`
child's removal after 4 seconds via a CSS class (`toast-leaving`, an
opacity/transform transition) added 300ms before the actual `remove()`
call, so it fades rather than disappearing instantly.
`prefers-reduced-motion: reduce` disables the CSS transition (the toast
still disappears at the same 4-second mark, just without animating).
### CSS
New `.toast-container` (fixed, top-right, stacked via flex column though
only one toast exists at a time), `.toast`, `.toast-success`,
`.toast-danger`, `.toast-info` rules using the existing color tokens
(`--success`/`--danger`/`--info` text on `--surface-raised` background,
consistent with the existing badge treatment). Existing
`button.htmx-request` / `.btn.htmx-request` rule dims the control
(`opacity: 0.6`) while a request is in flight — htmx adds/removes this
class automatically, no JS needed.
## 4. Testing
- `UserRepository.dashboard_row` — unit tests mirroring `dashboard_rows()`
coverage (found user returns expected fields, unknown id returns
`None`) in `tests/db/test_repositories.py`.
- Route-level tests (`tests/web/test_operations.py`,
`tests/web/test_account_web.py` or a new
`tests/web/test_live_sync_updates.py`) using the existing `TestClient` +
`fake_sync_manager` fixture, asserting on the returned HTML: the row's
`id="user-row-<id>"` element is present with updated fields, a
`hx-swap-oob="true"` toast div is present with the expected message
class, `/sync-all` emits one OOB row per outcome, and the
already-running path returns HTTP 200 (not 409) with an info toast.
- No htmx JS itself is unit-testable from Python; the actual in-browser
swap behavior (row updates without navigation, toast appears and
disappears) is verified manually via chrome-devtools, the same way the
next-sync countdown was verified.
## 5. Rollout
Files touched: `app/web/static/htmx.min.js` (new, vendored),
`app/web/static/app.js`, `app/web/static/style.css`, `app/web/templates/base.html`,
`app/web/templates/dashboard.html`, `app/web/templates/account/detail.html`,
`app/web/templates/fragments/user_row.html` (new),
`app/web/templates/fragments/account_status.html` (new),
`app/web/templates/fragments/toast.html` (new),
`app/web/templates/fragments/sync_all_form.html` (new),
`app/db/repositories.py`, `app/web/operations.py`, `app/web/account.py`.
`fragments/sync_result.html` is untouched (still used by retry/MFA,
out of scope).

View File

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

108
tests/auth/test_account.py Normal file
View File

@@ -0,0 +1,108 @@
from cryptography.fernet import Fernet
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.auth.account import authenticate_self_service
from app.db.models import Base
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def _make_session_and_cipher():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
cipher = CredentialCipher(Fernet.generate_key().decode("ascii"))
return factory(), cipher
def _seed_user(session, cipher, **overrides):
values = dict(
name="Max",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("max@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("mw-secret"),
garmin_email_enc=cipher.encrypt("max@garmin.example"),
garmin_password_enc=cipher.encrypt("garmin-secret"),
)
values.update(overrides)
return UserRepository(session).create(**values)
def test_authenticates_with_mywhoosh_credentials() -> None:
session, cipher = _make_session_and_cipher()
user = _seed_user(session, cipher)
result = authenticate_self_service(session, cipher, "max@mywhoosh.example", "mw-secret")
assert result is not None
assert result.id == user.id
def test_authenticates_with_garmin_credentials() -> None:
session, cipher = _make_session_and_cipher()
user = _seed_user(session, cipher)
result = authenticate_self_service(session, cipher, "max@garmin.example", "garmin-secret")
assert result is not None
assert result.id == user.id
def test_rejects_wrong_password() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "wrong") is None
def test_rejects_unknown_email() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "nobody@example.com", "mw-secret") is None
def test_rejects_mixed_email_and_password_from_different_accounts() -> None:
"""A MyWhoosh email paired with the Garmin password (or vice versa) for
the same user must not authenticate -- each pair is checked together."""
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "garmin-secret") is None
assert authenticate_self_service(session, cipher, "max@garmin.example", "mw-secret") is None
def test_rejects_empty_password() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "") is None
def test_picks_correct_user_among_several() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(
session,
cipher,
name="Anna",
mywhoosh_email_enc=cipher.encrypt("anna@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("anna-secret"),
garmin_email_enc=cipher.encrypt("anna@garmin.example"),
garmin_password_enc=cipher.encrypt("anna-garmin-secret"),
)
bob = _seed_user(
session,
cipher,
name="Bob",
mywhoosh_email_enc=cipher.encrypt("bob@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("bob-secret"),
garmin_email_enc=cipher.encrypt("bob@garmin.example"),
garmin_password_enc=cipher.encrypt("bob-garmin-secret"),
)
result = authenticate_self_service(session, cipher, "bob@mywhoosh.example", "bob-secret")
assert result is not None
assert result.id == bob.id

View File

@@ -9,7 +9,13 @@ from sqlalchemy.pool import StaticPool
from app.config import Settings
from app.db.models import Activity, Base, HealthState
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.db.repositories import (
ActivityRepository,
SchedulerSettingsRepository,
SyncRunRepository,
SystemLogRepository,
UserRepository,
)
from app.main import create_app
@@ -44,6 +50,16 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
return SyncRunRepository(db_session)
@pytest.fixture
def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository:
return SchedulerSettingsRepository(db_session)
@pytest.fixture
def system_log_repository(db_session: Session) -> SystemLogRepository:
return SystemLogRepository(db_session)
@pytest.fixture
def app(tmp_path: Path):
settings = Settings(

View File

@@ -1,4 +1,6 @@
from app.db.models import ActivityStatus, HealthState
from datetime import datetime, timedelta, timezone
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
def test_create_two_independent_users(db_session, user_repository) -> None:
@@ -64,3 +66,160 @@ def test_activity_external_id_is_unique_per_user(user_repository, activity_repos
assert inserted_again is False
assert created.id == same.id
assert same.status == ActivityStatus.DISCOVERED
def test_system_log_lists_most_recent_first(system_log_repository, user_repository) -> None:
user = user_repository.create(
name="Max",
enabled=True,
health_state=HealthState.HEALTHY,
mywhoosh_email_enc="mw-1",
mywhoosh_password_enc="mw-pw-1",
garmin_email_enc="g-1",
garmin_password_enc="g-pw-1",
)
system_log_repository.add(source="email_notification", message="first failure", user_id=user.id)
system_log_repository.add(source="email_notification", message="second failure", user_id=user.id)
entries = system_log_repository.list_recent()
assert [entry.message for entry in entries] == ["second failure", "first failure"]
assert entries[0].source == "email_notification"
assert entries[0].user_id == user.id
def test_system_log_respects_limit(system_log_repository) -> None:
for i in range(5):
system_log_repository.add(source="test", message=f"entry {i}")
entries = system_log_repository.list_recent(limit=2)
assert len(entries) == 2
def test_scheduler_settings_get_or_create_seeds_defaults(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=7)
assert row.day_interval_minutes == 7
assert row.night_interval_minutes == 7
assert row.day_start_hour == 6
assert row.night_start_hour == 22
def test_scheduler_settings_get_or_create_is_idempotent_after_update(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=5)
scheduler_settings_repository.update(row, day_interval_minutes=15)
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
assert reloaded.day_interval_minutes == 15
def test_scheduler_settings_update_persists_all_fields(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=5)
scheduler_settings_repository.update(
row,
day_start_hour=8,
night_start_hour=20,
day_interval_minutes=10,
night_interval_minutes=45,
)
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
assert reloaded.day_start_hour == 8
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

109
tests/db/test_session.py Normal file
View File

@@ -0,0 +1,109 @@
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.pool import StaticPool
from app.db.models import Base
from app.db.session import initialize_schema
def test_initialize_schema_creates_fresh_database() -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
initialize_schema(engine)
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns
assert "notification_email" in columns
finally:
engine.dispose()
def test_initialize_schema_adds_missing_columns_without_dropping_existing_rows() -> None:
"""Regression test: a database created before notify_email_enabled/
notification_email existed must gain those columns in place, keeping
every already-stored user row intact -- create_all() alone would not add
columns to a table that already exists."""
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
# Simulate the pre-existing production schema by creating every
# table via the current models, then dropping the two new columns
# back off sync_users the only way sqlite allows: rebuild the table.
Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.execute(text("ALTER TABLE sync_users RENAME TO sync_users_old"))
conn.execute(
text(
"""
CREATE TABLE sync_users (
id INTEGER PRIMARY KEY,
name VARCHAR(120) NOT NULL,
enabled BOOLEAN NOT NULL,
health_state VARCHAR NOT NULL,
mywhoosh_state VARCHAR(32) NOT NULL,
garmin_state VARCHAR(32) NOT NULL,
action_reason TEXT,
mywhoosh_email_enc TEXT NOT NULL,
mywhoosh_password_enc TEXT NOT NULL,
garmin_email_enc TEXT NOT NULL,
garmin_password_enc TEXT NOT NULL,
created_at DATETIME,
updated_at DATETIME
)
"""
)
)
conn.execute(
text(
"""
INSERT INTO sync_users (
id, name, enabled, health_state, mywhoosh_state, garmin_state,
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
garmin_email_enc, garmin_password_enc, created_at, updated_at
)
SELECT id, name, enabled, health_state, mywhoosh_state, garmin_state,
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
garmin_email_enc, garmin_password_enc, created_at, updated_at
FROM sync_users_old
"""
)
)
conn.execute(text("DROP TABLE sync_users_old"))
conn.execute(
text(
"""
INSERT INTO sync_users (
id, name, enabled, health_state, mywhoosh_state, garmin_state,
mywhoosh_email_enc, mywhoosh_password_enc, garmin_email_enc, garmin_password_enc
) VALUES (
1, 'Existing User', 1, 'healthy', 'connected', 'connected',
'enc-mw-email', 'enc-mw-pass', 'enc-garmin-email', 'enc-garmin-pass'
)
"""
)
)
columns_before = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" not in columns_before
initialize_schema(engine)
columns_after = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns_after
assert "notification_email" in columns_after
with engine.connect() as conn:
row = conn.execute(text("SELECT name, notify_email_enabled, notification_email FROM sync_users")).one()
assert row.name == "Existing User"
assert row.notify_email_enabled == 0
assert row.notification_email is None
finally:
engine.dispose()
def test_initialize_schema_is_idempotent() -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
initialize_schema(engine)
initialize_schema(engine)
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns
finally:
engine.dispose()

View File

@@ -1,9 +1,12 @@
import json
import httpx
import pytest
from app.mywhoosh.client import (
MyWhooshAuthError,
MyWhooshClient,
MyWhooshDeviceConflictError,
MyWhooshIntegrationError,
MyWhooshTransientError,
)
@@ -44,6 +47,42 @@ async def test_invalid_credentials_raise_auth_error(tmp_path) -> None:
await client.login("rider@example.com", "bad")
@pytest.mark.asyncio
async def test_device_conflict_message_raises_device_conflict_error(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"Success": False, "Message": "You are already logged in from another device."}
)
client = MyWhooshClient(
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
with pytest.raises(MyWhooshDeviceConflictError):
await client.login("rider@example.com", "secret")
@pytest.mark.asyncio
async def test_login_reuses_stable_device_id_across_calls(tmp_path) -> None:
seen_device_ids = []
async def handler(request: httpx.Request) -> httpx.Response:
seen_device_ids.append(json.loads(request.content)["DeviceId"])
return httpx.Response(
200,
json={"Success": True, "AccessToken": "access", "RefreshToken": "refresh", "WhooshId": "w-1"},
)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
await client.login("rider@example.com", "secret")
await client.login("rider@example.com", "secret")
assert len(seen_device_ids) == 2
assert seen_device_ids[0] == seen_device_ids[1]
assert seen_device_ids[0] == store.get_or_create_device_id()
@pytest.mark.asyncio
async def test_login_returns_json_array_raises_integration_error(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:

View File

@@ -41,3 +41,14 @@ def test_clear_removes_token_and_load_returns_none(tmp_path: Path) -> None:
assert store.load() is None
assert not store.path.exists()
def test_get_or_create_device_id_persists_and_is_stable(tmp_path: Path) -> None:
path = tmp_path / "tokens" / "7" / "mywhoosh.json"
device_id = MyWhooshTokenStore(path).get_or_create_device_id()
reloaded_id = MyWhooshTokenStore(path).get_or_create_device_id()
assert reloaded_id == device_id
device_id_path = path.with_name("device_id")
assert device_id_path.read_text("utf-8").strip() == device_id

View File

@@ -0,0 +1,69 @@
from unittest.mock import MagicMock, patch
from app.notifications.emailer import EmailNotifier
def _notifier(**overrides) -> EmailNotifier:
defaults = dict(
host="smtp.example.com",
port=587,
username="user@example.com",
password="secret",
from_address="sync@example.com",
use_tls=True,
)
defaults.update(overrides)
return EmailNotifier(**defaults)
def test_unconfigured_notifier_is_not_configured() -> None:
notifier = _notifier(host=None)
assert notifier.configured is False
def test_configured_notifier_is_configured() -> None:
assert _notifier().configured is True
def test_send_skips_silently_when_not_configured() -> None:
notifier = _notifier(host=None)
with patch("app.notifications.emailer.smtplib.SMTP") as smtp_cls:
notifier.send(to_address="user@example.com", subject="s", body="b")
smtp_cls.assert_not_called()
def test_send_uses_starttls_and_login_when_configured() -> None:
notifier = _notifier()
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance) as smtp_cls:
notifier.send(to_address="rider@example.com", subject="Action required", body="Check the dashboard")
smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10)
smtp_instance.starttls.assert_called_once()
smtp_instance.login.assert_called_once_with("user@example.com", "secret")
assert smtp_instance.send_message.call_count == 1
sent_message = smtp_instance.send_message.call_args[0][0]
assert sent_message["To"] == "rider@example.com"
assert sent_message["From"] == "sync@example.com"
assert sent_message["Subject"] == "Action required"
def test_send_skips_login_without_credentials() -> None:
notifier = _notifier(username=None, password=None)
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
notifier.send(to_address="rider@example.com", subject="s", body="b")
smtp_instance.login.assert_not_called()
def test_send_skips_starttls_when_disabled() -> None:
notifier = _notifier(use_tls=False)
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
notifier.send(to_address="rider@example.com", subject="s", body="b")
smtp_instance.starttls.assert_not_called()

View File

@@ -28,3 +28,11 @@ class FakeGarminUploader:
if self.error is not None:
raise self.error
return self.result
class FakeNotifier:
def __init__(self) -> None:
self.sent: list[dict] = []
def send(self, *, to_address: str, subject: str, body: str) -> None:
self.sent.append({"to_address": to_address, "subject": subject, "body": body})

View File

@@ -4,12 +4,14 @@ import pytest
from sqlalchemy import select
from app.db.models import ActivityStatus, HealthState, SyncRun, SyncRunStatus, SyncUser
from app.db.repositories import UserRepository
from app.db.repositories import SystemLogRepository, UserRepository
from app.garmin.uploader import UploadResult
from app.mywhoosh.client import MyWhooshDeviceConflictError
from app.mywhoosh.models import MyWhooshActivity
from app.notifications.emailer import EmailNotifier
from app.sync.manager import SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user
from tests.sync.fakes import FakeMyWhooshClient
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier
@pytest.mark.asyncio
@@ -188,6 +190,198 @@ async def test_garmin_action_required_cleared_after_successful_import(
assert reloaded.health_state == HealthState.HEALTHY
class DeviceConflictMyWhooshClient:
"""Fake MyWhoosh client that always raises MyWhooshDeviceConflictError
from list_activities, simulating MyWhoosh's "already logged in from
another device" response."""
async def list_activities(self, email: str, password: str):
raise MyWhooshDeviceConflictError("You are already logged in from another device.")
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
raise AssertionError("download_fit should not be reached in this test")
@pytest.mark.asyncio
async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser, session_factory, cipher, settings) -> None:
"""A MyWhoosh device-conflict response must be distinguishable in the UI
from a generic auth failure, so users get an actionable hint instead of
being told to re-check their password."""
mywhoosh = DeviceConflictMyWhooshClient()
converter = FakeFitConverter()
garmin = FakeGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
outcome = await manager.sync_user(seeded_user.id)
assert outcome.status == "failed"
assert "another device" in outcome.message
with session_factory() as session:
reloaded = UserRepository(session).get(seeded_user.id)
assert reloaded.action_reason == "mywhoosh_device_conflict"
assert reloaded.mywhoosh_state == "device_conflict"
assert reloaded.health_state == HealthState.ACTION_REQUIRED
@pytest.mark.asyncio
async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
assert len(notifier.sent) == 1
assert notifier.sent[0]["to_address"] == "alerts@example.com"
assert "another device" in notifier.sent[0]["body"]
@pytest.mark.asyncio
async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None:
user_id = None
with session_factory() as session:
user = _create_user(session, cipher)
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
assert notifier.sent == []
@pytest.mark.asyncio
async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
await manager.sync_user(user_id)
assert len(notifier.sent) == 1
class FailingNotifier:
def send(self, *, to_address: str, subject: str, body: str) -> None:
raise RuntimeError("SMTP connection refused")
@pytest.mark.asyncio
async def test_email_send_failure_is_recorded_in_system_log_and_does_not_break_sync(
session_factory, cipher, settings
) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=FailingNotifier(),
)
outcome = await manager.sync_user(user_id)
assert outcome.status == "failed"
with session_factory() as session:
entries = SystemLogRepository(session).list_recent()
assert len(entries) == 1
assert entries[0].source == "email_notification"
assert entries[0].user_id == user_id
assert "SMTP connection refused" in entries[0].message
assert "alerts@example.com" in entries[0].message
@pytest.mark.asyncio
async def test_unconfigured_smtp_is_recorded_in_system_log_not_silently_dropped(
session_factory, cipher, settings
) -> None:
"""Regression test: an EmailNotifier with no SMTP_HOST configured skips
sending without raising, which used to look identical to "notifications
disabled" -- an opted-in user got neither an email nor any trace of why,
with nothing to debug from. This must now leave a system log entry."""
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
unconfigured_notifier = EmailNotifier(
host=None, port=587, username=None, password=None, from_address=None, use_tls=True
)
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=unconfigured_notifier,
)
await manager.sync_user(user_id)
with session_factory() as session:
entries = SystemLogRepository(session).list_recent()
assert len(entries) == 1
assert entries[0].source == "email_notification"
assert "SMTP is not configured" in entries[0].message
assert "alerts@example.com" in entries[0].message
@pytest.mark.asyncio
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
session_factory, cipher, settings, seeded_user: SyncUser

View File

@@ -1,8 +1,14 @@
import asyncio
from datetime import datetime
import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from app.sync.scheduler import SyncScheduler
from app.db.models import Base
from app.db.repositories import SchedulerSettingsRepository
from app.db.session import create_session_factory
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime
class FakeSyncManager:
@@ -40,3 +46,91 @@ async def test_scheduler_stop_cancels_the_loop() -> None:
calls_after_stop = fake.calls
await asyncio.sleep(0.05)
assert fake.calls == calls_after_stop
@pytest.mark.parametrize(
("hour", "day_start", "night_start", "expected"),
[
(6, 6, 22, True),
(21, 6, 22, True),
(5, 6, 22, False),
(22, 6, 22, False),
(0, 6, 22, False),
# wraps past midnight: day period is [20, 6)
(23, 20, 6, True),
(2, 20, 6, True),
(10, 20, 6, False),
(20, 20, 6, True),
(6, 20, 6, False),
# degenerate: identical start hours means always day
(13, 9, 9, True),
],
)
def test_is_daytime(hour, day_start, night_start, expected) -> None:
assert is_daytime(hour, day_start, night_start) is expected
@pytest.fixture
def db_session_factory():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = create_session_factory(engine)
try:
yield factory
finally:
engine.dispose()
@pytest.mark.asyncio
async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None:
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
)
assert provider() == 5 * 60
@pytest.mark.asyncio
async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None:
with db_session_factory() as session:
repo = SchedulerSettingsRepository(session)
row = repo.get_or_create(default_minutes=5)
repo.update(row, night_interval_minutes=30)
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0)
)
assert provider() == 30 * 60
@pytest.mark.asyncio
async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None:
with db_session_factory() as session:
repo = SchedulerSettingsRepository(session)
row = repo.get_or_create(default_minutes=5)
repo.update(row, day_interval_minutes=1, night_interval_minutes=1)
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
)
fake = FakeSyncManager()
scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider)
await scheduler.run_once()
expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
assert abs(delta - expected_seconds) < 1
@pytest.mark.asyncio
async def test_scheduler_falls_back_when_interval_provider_raises() -> None:
def broken_provider():
raise RuntimeError("db unavailable")
fake = FakeSyncManager()
scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider)
await scheduler.run_once()
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
assert abs(delta - 42) < 1

View File

@@ -0,0 +1,335 @@
from fastapi.testclient import TestClient
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def extract_csrf(html: str) -> str:
marker = 'name="csrf_token" value="'
start = html.index(marker) + len(marker)
end = html.index('"', start)
return html[start:end]
def admin_login(client: TestClient) -> None:
page = client.get("/login")
csrf = extract_csrf(page.text)
response = client.post(
"/login",
data={"password": "admin-secret", "csrf_token": csrf},
follow_redirects=False,
)
assert response.status_code == 303
def create_user_via_admin(client: TestClient, **overrides) -> int:
admin_login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
payload = {
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "mw-secret",
"garmin_email": "max@garmin.example",
"garmin_password": "garmin-secret",
"enabled": "on",
}
payload.update(overrides)
response = client.post("/users", data=payload, follow_redirects=False)
assert response.status_code == 303
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
client.cookies.clear()
return user_id
def account_login(client: TestClient, *, email: str, password: str):
page = client.get("/account-login")
csrf = extract_csrf(page.text)
return client.post(
"/account-login",
data={"csrf_token": csrf, "email": email, "password": password},
follow_redirects=False,
)
def test_login_with_mywhoosh_credentials_succeeds(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="mw-secret")
assert response.status_code == 303
assert response.headers["location"] == "/account"
def test_login_with_garmin_credentials_succeeds(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@garmin.example", password="garmin-secret")
assert response.status_code == 303
assert response.headers["location"] == "/account"
def test_login_with_wrong_password_is_rejected(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="wrong")
assert response.status_code == 401
assert "Invalid email or password" in response.text
def test_login_never_makes_the_stored_password_appear_in_response(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="wrong")
assert "mw-secret" not in response.text
def test_account_detail_requires_login(client: TestClient) -> None:
response = client.get("/account", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_account_detail_shows_own_status_only(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.get("/account")
assert response.status_code == 200
assert "Max" in response.text
assert "mw-secret" not in response.text
assert "garmin-secret" not in response.text
def test_account_edit_page_prefills_emails_not_passwords(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.get("/account/edit")
assert response.status_code == 200
assert "max@mywhoosh.example" in response.text
assert "max@garmin.example" in response.text
assert "mw-secret" not in response.text
assert "garmin-secret" not in response.text
def test_account_update_blank_password_preserves_existing_password(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
def test_account_update_can_set_new_password(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "new-mw-secret",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "new-mw-secret"
def test_account_update_cannot_change_name_or_enabled(client: TestClient) -> None:
"""Self-service editing must not expose name/enabled -- those stay
administrative decisions, not something the account owner can flip."""
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user.name == "Max"
assert user.enabled is True
def test_account_update_persists_notification_preferences(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
"notify_email_enabled": "on",
"notification_email": "alerts@example.com",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
def test_account_edit_rejects_invalid_csrf(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.post(
"/account/edit",
data={
"csrf_token": "invalid-token",
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
)
assert response.status_code == 403
def test_logout_clears_session(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account-logout", data={"csrf_token": csrf}, follow_redirects=False)
assert response.status_code == 303
response = client.get("/account", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_cannot_view_other_users_account(client: TestClient) -> None:
"""Each self-service session is bound to the user_id captured at login;
another user created afterwards must not be reachable from it."""
create_user_via_admin(client, name="Max")
account_login(client, email="max@mywhoosh.example", password="mw-secret")
with client.app.state.session_factory() as session:
UserRepository(session).create(
name="Other",
enabled=True,
mywhoosh_email_enc="unused",
mywhoosh_password_enc="unused",
garmin_email_enc="unused",
garmin_password_enc="unused",
)
response = client.get("/account")
assert response.status_code == 200
assert "Max" in response.text
assert "Other" not in response.text
def test_account_sync_triggers_own_user_only(app, client: TestClient, fake_sync_manager) -> None:
user_id = 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 fake_sync_manager.user_calls == [user_id]
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()
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
assert response.headers["location"] == "/account-login"
def test_account_sync_rejects_invalid_csrf(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
response = client.post("/account/sync", data={"csrf_token": "invalid-token"})
assert response.status_code == 403
assert fake_sync_manager.user_calls == []

View File

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

View File

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

View File

@@ -1,5 +1,21 @@
from fastapi.testclient import TestClient
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:
response = authenticated_client.post(
@@ -10,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()
@@ -29,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",
@@ -60,3 +119,142 @@ def test_system_page_shows_scheduler_state(app, authenticated_client) -> None:
assert "1.0.0" in response.text
assert "5" in response.text # sync_interval_minutes
assert "0" in response.text # user_count / activity_count fresh DB
class _FakeScheduler:
def __init__(self) -> None:
self.last_tick = None
self.next_tick = None
def test_system_page_shows_empty_log_state(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.get("/system")
assert response.status_code == 200
assert "No system log entries" in response.text
def test_system_page_shows_recorded_log_entries(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
with app.state.session_factory() as session:
SystemLogRepository(session).add(
source="email_notification", message="Failed to email alerts@example.com: SMTP timeout"
)
response = authenticated_client.get("/system")
assert response.status_code == 200
assert "email_notification" in response.text
assert "SMTP timeout" in response.text
def _extract_csrf(html: str) -> str:
marker = 'name="csrf_token" value="'
start = html.index(marker) + len(marker)
end = html.index('"', start)
return html[start:end]
def test_scheduler_settings_page_prefills_defaults(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.get("/system")
assert response.status_code == 200
assert 'id="day_start_hour"' in response.text
assert 'value="6"' in response.text
assert 'value="22"' in response.text
def test_update_scheduler_settings_persists_and_takes_effect_next_tick(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "8",
"night_start_hour": "20",
"day_interval_minutes": "3",
"night_interval_minutes": "45",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/system"
with app.state.session_factory() as session:
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=5)
assert row.day_start_hour == 8
assert row.night_start_hour == 20
assert row.day_interval_minutes == 3
assert row.night_interval_minutes == 45
def test_update_scheduler_settings_rejects_out_of_range_hour(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "24",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
)
assert response.status_code == 400
def test_update_scheduler_settings_rejects_non_positive_interval(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "0",
"night_interval_minutes": "5",
},
)
assert response.status_code == 400
def test_update_scheduler_settings_requires_admin(client: TestClient) -> None:
response = client.post(
"/system/scheduler-settings",
data={
"csrf_token": "whatever",
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_update_scheduler_settings_rejects_invalid_csrf(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": "invalid-token",
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
)
assert response.status_code == 403

View File

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

View File

@@ -1,6 +1,7 @@
from fastapi.testclient import TestClient
from app.db.repositories import UserRepository
from app.db.models import SyncRunStatus
from app.db.repositories import SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher
@@ -171,6 +172,30 @@ def test_user_detail_page_shows_no_secrets(client: TestClient) -> None:
assert "Max" in response.text
def test_user_detail_page_shows_recent_sync_runs(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
with client.app.state.session_factory() as session:
repo = SyncRunRepository(session)
run = repo.start(user_id)
repo.finish(
run.id,
status=SyncRunStatus.SUCCESS,
discovered=3,
imported=2,
skipped=1,
failed=0,
)
response = client.get(f"/users/{user_id}")
assert response.status_code == 200
assert "Recent sync runs" in response.text
assert "success" in response.text
assert ">3<" in response.text
assert ">2<" in response.text
def test_unknown_user_returns_404_for_detail(client: TestClient) -> None:
login(client)
response = client.get("/users/999999")
@@ -341,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None:
assert response.status_code == 400
def test_create_user_without_notifications_requires_no_email(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={"csrf_token": csrf, **_create_payload()},
follow_redirects=False,
)
assert response.status_code == 303
def test_create_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={"csrf_token": csrf, **_create_payload(notify_email_enabled="on")},
)
assert response.status_code == 400
def test_create_user_persists_notification_preferences(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={
"csrf_token": csrf,
**_create_payload(notify_email_enabled="on", notification_email="alerts@example.com"),
},
follow_redirects=False,
)
assert response.status_code == 303
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user is not None
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
def test_update_user_can_enable_notifications_without_reentering_passwords(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
edit_page = client.get(f"/users/{user_id}/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
f"/users/{user_id}",
data={
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@example.com",
"mywhoosh_password": "",
"garmin_email": "max-garmin@example.com",
"garmin_password": "",
"enabled": "on",
"notify_email_enabled": "on",
"notification_email": "alerts@example.com",
},
follow_redirects=True,
)
assert response.status_code == 200
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user is not None
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
def test_update_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
edit_page = client.get(f"/users/{user_id}/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
f"/users/{user_id}",
data={
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@example.com",
"mywhoosh_password": "",
"garmin_email": "max-garmin@example.com",
"garmin_password": "",
"enabled": "on",
"notify_email_enabled": "on",
"notification_email": "",
},
)
assert response.status_code == 400
def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)