diff --git a/.env.example b/.env.example
index 608524d..b956598 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,8 @@ POLL_INTERVAL_SECONDS=3600
DATA_DIR=/data
LOG_LEVEL=INFO
DRY_RUN=true
+DASHBOARD_HOST=0.0.0.0
+DASHBOARD_PORT=8080
MYWHOOSH_LOGIN_URL=https://www.event.mywhoosh.com/login/
MYWHOOSH_ACTIVITY_URL=https://event.mywhoosh.com/user/activities
diff --git a/README.md b/README.md
index 8c76803..398f2f1 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,14 @@ docker compose logs -f sync
The default polling interval is hourly.
+Open the local dashboard:
+
+```text
+http://localhost:8080/
+```
+
+It shows the last sync check state and the last five uploaded Garmin activities.
+
## Configuration
Required values:
@@ -61,6 +69,8 @@ POLL_INTERVAL_SECONDS=3600
DATA_DIR=/data
LOG_LEVEL=INFO
DRY_RUN=false
+DASHBOARD_HOST=0.0.0.0
+DASHBOARD_PORT=8080
MYWHOOSH_LOGIN_URL=https://www.mywhoosh.com/login/
MYWHOOSH_ACTIVITY_URL=https://www.mywhoosh.com/profile/
MYWHOOSH_ACTIVITIES_BUTTON_TEXT=ACTIVITIES
diff --git a/src/mywhoosh_garmin_sync/config.py b/src/mywhoosh_garmin_sync/config.py
index 186aba9..0ec0f4f 100644
--- a/src/mywhoosh_garmin_sync/config.py
+++ b/src/mywhoosh_garmin_sync/config.py
@@ -57,6 +57,8 @@ class Settings:
db_path: Path
log_level: str
dry_run: bool
+ dashboard_host: str
+ dashboard_port: int
mywhoosh_login_url: str
mywhoosh_activity_url: str
@@ -106,6 +108,8 @@ class Settings:
db_path=Path(os.getenv("STATE_DB", str(data_dir / "state.sqlite3"))),
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
dry_run=_bool_env("DRY_RUN", False),
+ dashboard_host=os.getenv("DASHBOARD_HOST", "0.0.0.0"),
+ dashboard_port=_int_env("DASHBOARD_PORT", 8080),
mywhoosh_login_url=os.getenv(
"MYWHOOSH_LOGIN_URL", "https://www.mywhoosh.com/login/"
),
diff --git a/src/mywhoosh_garmin_sync/dashboard.py b/src/mywhoosh_garmin_sync/dashboard.py
new file mode 100644
index 0000000..3ad7e71
--- /dev/null
+++ b/src/mywhoosh_garmin_sync/dashboard.py
@@ -0,0 +1,369 @@
+from __future__ import annotations
+
+import json
+import logging
+import threading
+from datetime import datetime
+from html import escape
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import urlparse
+
+from .state import ActivityStore, ActivitySummary, SyncCheckSummary
+
+logger = logging.getLogger(__name__)
+
+
+class DashboardServer:
+ def __init__(self, store: ActivityStore, host: str, port: int) -> None:
+ self.store = store
+ self.host = host
+ self.port = port
+ self._server: ThreadingHTTPServer | None = None
+ self._thread: threading.Thread | None = None
+
+ def start(self) -> None:
+ handler = _handler_factory(self.store)
+ self._server = ThreadingHTTPServer((self.host, self.port), handler)
+ self._thread = threading.Thread(
+ target=self._server.serve_forever,
+ name="dashboard-http",
+ daemon=True,
+ )
+ self._thread.start()
+ logger.info("Dashboard listening on http://%s:%s", self.host, self.port)
+
+ def stop(self) -> None:
+ if self._server is not None:
+ self._server.shutdown()
+ self._server.server_close()
+ if self._thread is not None:
+ self._thread.join(timeout=5)
+
+
+def render_dashboard(
+ latest_check: SyncCheckSummary | None,
+ recent_uploads: list[ActivitySummary],
+ refresh_seconds: int = 30,
+) -> str:
+ status_label = _status_label(latest_check)
+ status_class = _status_class(latest_check)
+ message = _status_message(latest_check)
+ checked_at = latest_check.finished_at if latest_check else None
+ started_at = latest_check.started_at if latest_check else None
+ rows = "\n".join(_activity_row(activity) for activity in recent_uploads)
+ if not rows:
+ rows = """
+
+ | No uploaded activities recorded yet. |
+
+ """
+
+ checked_markup = escape(_format_german_datetime(checked_at) or "Nie")
+ started_markup = escape(_format_german_datetime(started_at) or "Nie")
+ count_markup = _count_markup(latest_check)
+ error_type_markup = _error_type_markup(latest_check)
+
+ return f"""
+
+
+
+
+
+ MyWhoosh Garmin Sync
+
+
+
+
+ MyWhoosh Garmin Sync
+
+
+
{escape(message)}
+
+ Gestartet: {started_markup}
+ Beendet: {checked_markup}
+ {count_markup}
+ {error_type_markup}
+
+
+
+ Last 5 Uploaded Activities
+
+
+
+ | Title |
+ Uploaded |
+ Garmin ID |
+ Source |
+ Converted File |
+
+
+
+ {rows}
+
+
+
+
+
+
+"""
+
+
+def render_health(latest_check: SyncCheckSummary | None) -> str:
+ payload = {
+ "status": latest_check.status if latest_check else "never_checked",
+ "message": latest_check.message if latest_check else None,
+ "error_type": latest_check.error_type if latest_check else None,
+ "finished_at": latest_check.finished_at if latest_check else None,
+ }
+ return json.dumps(payload, separators=(",", ":"))
+
+
+def _handler_factory(store: ActivityStore) -> type[BaseHTTPRequestHandler]:
+ class DashboardRequestHandler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ path = urlparse(self.path).path
+ try:
+ store.initialize()
+ if path == "/":
+ latest = store.get_latest_sync_check()
+ uploads = store.get_recent_uploaded_activities(5)
+ body = render_dashboard(latest, uploads)
+ self._send(HTTPStatus.OK, "text/html; charset=utf-8", body)
+ return
+
+ if path == "/healthz":
+ body = render_health(store.get_latest_sync_check())
+ self._send(HTTPStatus.OK, "application/json", body)
+ return
+
+ self._send(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", "Not found")
+ except Exception:
+ logger.exception("Dashboard request failed")
+ self._send(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ "text/plain; charset=utf-8",
+ "Dashboard error",
+ )
+
+ def log_message(self, fmt: str, *args: object) -> None:
+ logger.debug("Dashboard: " + fmt, *args)
+
+ def _send(self, status: HTTPStatus, content_type: str, body: str) -> None:
+ encoded = body.encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(encoded)))
+ self.end_headers()
+ self.wfile.write(encoded)
+
+ return DashboardRequestHandler
+
+
+def _status_label(latest_check: SyncCheckSummary | None) -> str:
+ if latest_check is None:
+ return "Never checked"
+ if latest_check.status == "ok":
+ return "OK"
+ if latest_check.status == "problem":
+ return "Problem"
+ return "Running"
+
+
+def _status_class(latest_check: SyncCheckSummary | None) -> str:
+ if latest_check is None:
+ return "running"
+ if latest_check.status == "problem":
+ return "problem"
+ if latest_check.status == "ok":
+ return "ok"
+ return "running"
+
+
+def _status_message(latest_check: SyncCheckSummary | None) -> str:
+ if latest_check is None:
+ return "No sync check has finished yet."
+ if latest_check.message:
+ return latest_check.message
+ if latest_check.status == "ok":
+ return "Last sync check completed successfully."
+ if latest_check.status == "problem":
+ return "Last sync check found a problem."
+ return "Sync check is running."
+
+
+def _count_markup(latest_check: SyncCheckSummary | None) -> str:
+ if latest_check is None:
+ return "Downloaded: 0, processed: 0, failed: 0"
+ return escape(
+ f"Downloaded: {latest_check.downloaded_count}, "
+ f"processed: {latest_check.processed_count}, "
+ f"failed: {latest_check.failed_count}"
+ )
+
+
+def _error_type_markup(latest_check: SyncCheckSummary | None) -> str:
+ if latest_check is None or not latest_check.error_type:
+ return ""
+ return f"Problem type: {escape(latest_check.error_type)}"
+
+
+def _activity_row(activity: ActivitySummary) -> str:
+ title = escape(activity.title or "MyWhoosh activity")
+ uploaded = escape(
+ _format_german_datetime(activity.uploaded_at or activity.updated_at)
+ or activity.uploaded_at
+ or activity.updated_at
+ )
+ garmin_id = escape(activity.garmin_activity_id or "-")
+ source = _link(activity.source_url, "Open") if activity.source_url else "-"
+ converted = escape(activity.converted_path or "-")
+ return f"""
+
+ | {title} |
+ {uploaded} |
+ {garmin_id} |
+ {source} |
+ {converted} |
+
+ """
+
+
+def _link(url: str, label: str) -> str:
+ safe_url = escape(url, quote=True)
+ return f'{escape(label)}'
+
+
+def _format_german_datetime(value: str | None) -> str | None:
+ if not value:
+ return None
+
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return value
+
+ return parsed.strftime("%d.%m.%Y, %H:%M Uhr")
diff --git a/src/mywhoosh_garmin_sync/service.py b/src/mywhoosh_garmin_sync/service.py
index 24d8d22..7495809 100644
--- a/src/mywhoosh_garmin_sync/service.py
+++ b/src/mywhoosh_garmin_sync/service.py
@@ -3,9 +3,11 @@ from __future__ import annotations
import asyncio
import logging
import signal
+from dataclasses import dataclass
from pathlib import Path
from .config import Settings
+from .dashboard import DashboardServer
from .fit_device import GarminDevice, convert_fit_device
from .garmin import GarminUploadBlocked, GarminUploader
from .models import DownloadedActivity
@@ -15,6 +17,13 @@ from .state import ActivityStore, sha256_file
logger = logging.getLogger(__name__)
+@dataclass(frozen=True)
+class ProcessingResult:
+ status: str
+ error_type: str | None = None
+ message: str | None = None
+
+
class SyncService:
def __init__(
self,
@@ -36,43 +45,102 @@ class SyncService:
async def serve(self) -> None:
stop_event = asyncio.Event()
+ dashboard = DashboardServer(
+ self.store,
+ host=self.settings.dashboard_host,
+ port=self.settings.dashboard_port,
+ )
+ dashboard.start()
try:
- loop = asyncio.get_running_loop()
- for sig in (signal.SIGINT, signal.SIGTERM):
- loop.add_signal_handler(sig, stop_event.set)
- except (NotImplementedError, RuntimeError):
- pass
-
- logger.info(
- "Starting sync loop; interval=%ss dry_run=%s",
- self.settings.poll_interval_seconds,
- self.settings.dry_run,
- )
- while not stop_event.is_set():
try:
- await self.run_once()
- except Exception:
- logger.exception("Sync cycle failed")
+ loop = asyncio.get_running_loop()
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ loop.add_signal_handler(sig, stop_event.set)
+ except (NotImplementedError, RuntimeError):
+ pass
- try:
- await asyncio.wait_for(
- stop_event.wait(), timeout=self.settings.poll_interval_seconds
- )
- except TimeoutError:
- continue
+ logger.info(
+ "Starting sync loop; interval=%ss dry_run=%s",
+ self.settings.poll_interval_seconds,
+ self.settings.dry_run,
+ )
+ while not stop_event.is_set():
+ try:
+ await self.run_once()
+ except Exception:
+ logger.exception("Sync cycle failed")
+
+ try:
+ await asyncio.wait_for(
+ stop_event.wait(), timeout=self.settings.poll_interval_seconds
+ )
+ except TimeoutError:
+ continue
+ finally:
+ dashboard.stop()
async def run_once(self) -> None:
self.store.initialize()
- downloads = await self.crawler.download_new_activities(
- self.store.is_terminal_source
- )
- logger.info("Downloaded %d candidate activities", len(downloads))
+ check_id = self.store.begin_sync_check()
+ downloads: list[DownloadedActivity] = []
+ processed_count = 0
+ failed_count = 0
+ first_error_type: str | None = None
+ first_error_message: str | None = None
+ try:
+ downloads = await self.crawler.download_new_activities(
+ self.store.is_terminal_source
+ )
+ logger.info("Downloaded %d candidate activities", len(downloads))
- for activity in downloads:
- await asyncio.to_thread(self._process_activity, activity)
+ for activity in downloads:
+ result = await asyncio.to_thread(self._process_activity, activity)
+ processed_count += 1
+ if result.status == "failed":
+ failed_count += 1
+ first_error_type = first_error_type or result.error_type
+ first_error_message = first_error_message or result.message
- def _process_activity(self, activity: DownloadedActivity) -> None:
+ if failed_count:
+ message = (
+ f"{failed_count} of {processed_count} downloaded activities failed."
+ )
+ if first_error_message:
+ message = f"{message} First error: {first_error_message}"
+ self.store.finish_sync_check(
+ check_id,
+ status="problem",
+ message=message,
+ error_type=first_error_type or "sync_error",
+ downloaded_count=len(downloads),
+ processed_count=processed_count,
+ failed_count=failed_count,
+ )
+ return
+
+ self.store.finish_sync_check(
+ check_id,
+ status="ok",
+ message=_ok_message(len(downloads), processed_count),
+ error_type=None,
+ downloaded_count=len(downloads),
+ processed_count=processed_count,
+ failed_count=0,
+ )
+ except Exception as exc:
+ self.store.finish_sync_check(
+ check_id,
+ status="problem",
+ message=str(exc),
+ error_type=_classify_sync_exception(exc),
+ downloaded_count=len(downloads),
+ processed_count=processed_count,
+ failed_count=failed_count,
+ )
+ raise
+
+ def _process_activity(self, activity: DownloadedActivity) -> ProcessingResult:
try:
raw_hash = sha256_file(activity.raw_path)
self.store.record_downloaded(
@@ -88,7 +156,7 @@ class SyncService:
activity.source_ref,
"Raw file hash was already uploaded from another source.",
)
- return
+ return ProcessingResult("duplicate")
converted_path = self._converted_path(activity)
result = convert_fit_device(activity.raw_path, converted_path, self.device)
@@ -105,29 +173,67 @@ class SyncService:
activity.source_ref,
"Converted file hash was already uploaded from another source.",
)
- return
+ return ProcessingResult("duplicate")
if self.settings.dry_run:
logger.info("Dry run enabled; not uploading %s", converted_path)
- return
+ return ProcessingResult("converted")
upload = self.uploader.upload(converted_path)
if upload.duplicate:
self.store.mark_duplicate(
activity.source_ref, "Garmin reported a duplicate activity."
)
+ return ProcessingResult("duplicate")
else:
self.store.mark_uploaded(
activity.source_ref,
garmin_activity_id=upload.garmin_activity_id,
)
+ return ProcessingResult("uploaded")
except GarminUploadBlocked as exc:
logger.error("Upload blocked: %s", exc)
self.store.mark_failed(activity.source_ref, str(exc))
+ return ProcessingResult("failed", "garmin_login", str(exc))
except Exception as exc:
logger.exception("Failed processing %s", activity.raw_path)
self.store.mark_failed(activity.source_ref, str(exc))
+ return ProcessingResult(
+ "failed",
+ _classify_activity_exception(exc),
+ str(exc),
+ )
def _converted_path(self, activity: DownloadedActivity) -> Path:
return self.settings.converted_dir / activity.raw_path.name
+
+def _ok_message(downloaded_count: int, processed_count: int) -> str:
+ if downloaded_count == 0:
+ return "OK: no new activities found."
+ if processed_count == 1:
+ return "OK: processed 1 activity."
+ return f"OK: processed {processed_count} activities."
+
+
+def _classify_sync_exception(exc: Exception) -> str:
+ if isinstance(exc, GarminUploadBlocked):
+ return "garmin_login"
+
+ text = str(exc).lower()
+ if any(token in text for token in ("captcha", "bot challenge", "challenge", "cloudflare")):
+ return "mywhoosh_challenge"
+ if any(token in text for token in ("mywhoosh", "login", "credential", "mfa", "auth")):
+ return "mywhoosh_login"
+ if "garmin" in text:
+ return "garmin_upload"
+ return "sync_error"
+
+
+def _classify_activity_exception(exc: Exception) -> str:
+ text = str(exc).lower()
+ if any(token in text for token in ("mfa", "login", "auth", "credential")):
+ return "garmin_login"
+ if any(token in text for token in ("garmin", "upload", "connect")):
+ return "garmin_upload"
+ return "sync_error"
diff --git a/src/mywhoosh_garmin_sync/state.py b/src/mywhoosh_garmin_sync/state.py
index 6dec7ae..c88e3f0 100644
--- a/src/mywhoosh_garmin_sync/state.py
+++ b/src/mywhoosh_garmin_sync/state.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import sqlite3
from contextlib import contextmanager
+from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Iterator
@@ -22,6 +23,30 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest()
+@dataclass(frozen=True)
+class ActivitySummary:
+ source_ref: str
+ title: str | None
+ source_url: str | None
+ converted_path: str | None
+ garmin_activity_id: str | None
+ uploaded_at: str | None
+ updated_at: str
+
+
+@dataclass(frozen=True)
+class SyncCheckSummary:
+ id: int
+ started_at: str
+ finished_at: str | None
+ status: str
+ message: str | None
+ error_type: str | None
+ downloaded_count: int
+ processed_count: int
+ failed_count: int
+
+
class ActivityStore:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
@@ -59,6 +84,124 @@ class ActivityStore:
ON activities(converted_sha256)
"""
)
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS sync_checks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ status TEXT NOT NULL,
+ message TEXT,
+ error_type TEXT,
+ downloaded_count INTEGER NOT NULL DEFAULT 0,
+ processed_count INTEGER NOT NULL DEFAULT 0,
+ failed_count INTEGER NOT NULL DEFAULT 0
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_sync_checks_started_at
+ ON sync_checks(started_at)
+ """
+ )
+
+ def begin_sync_check(self) -> int:
+ with self._connect() as conn:
+ cursor = conn.execute(
+ """
+ INSERT INTO sync_checks (started_at, status)
+ VALUES (?, 'running')
+ """,
+ (utc_now(),),
+ )
+ return int(cursor.lastrowid)
+
+ def finish_sync_check(
+ self,
+ check_id: int,
+ status: str,
+ message: str | None,
+ error_type: str | None,
+ downloaded_count: int,
+ processed_count: int,
+ failed_count: int,
+ ) -> None:
+ self._execute(
+ """
+ UPDATE sync_checks
+ SET finished_at = ?,
+ status = ?,
+ message = ?,
+ error_type = ?,
+ downloaded_count = ?,
+ processed_count = ?,
+ failed_count = ?
+ WHERE id = ?
+ """,
+ (
+ utc_now(),
+ status,
+ message[:1000] if message else None,
+ error_type,
+ downloaded_count,
+ processed_count,
+ failed_count,
+ check_id,
+ ),
+ )
+
+ def get_latest_sync_check(self) -> SyncCheckSummary | None:
+ row = self._fetch_one(
+ """
+ SELECT id, started_at, finished_at, status, message, error_type,
+ downloaded_count, processed_count, failed_count
+ FROM sync_checks
+ ORDER BY started_at DESC, id DESC
+ LIMIT 1
+ """,
+ (),
+ )
+ if row is None:
+ return None
+ return SyncCheckSummary(
+ id=row["id"],
+ started_at=row["started_at"],
+ finished_at=row["finished_at"],
+ status=row["status"],
+ message=row["message"],
+ error_type=row["error_type"],
+ downloaded_count=row["downloaded_count"],
+ processed_count=row["processed_count"],
+ failed_count=row["failed_count"],
+ )
+
+ def get_recent_uploaded_activities(self, limit: int = 5) -> list[ActivitySummary]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT source_ref, title, source_url, converted_path,
+ garmin_activity_id, uploaded_at, updated_at
+ FROM activities
+ WHERE status = 'uploaded'
+ ORDER BY uploaded_at DESC, updated_at DESC, source_ref DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+
+ return [
+ ActivitySummary(
+ source_ref=row["source_ref"],
+ title=row["title"],
+ source_url=row["source_url"],
+ converted_path=row["converted_path"],
+ garmin_activity_id=row["garmin_activity_id"],
+ uploaded_at=row["uploaded_at"],
+ updated_at=row["updated_at"],
+ )
+ for row in rows
+ ]
def is_terminal_source(self, source_ref: str) -> bool:
row = self._fetch_one(
@@ -216,4 +359,3 @@ class ActivityStore:
) -> sqlite3.Row | None:
with self._connect() as conn:
return conn.execute(sql, params).fetchone()
-
diff --git a/tests/conftest.py b/tests/conftest.py
index ba32946..7e89594 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,8 +8,6 @@ from mywhoosh_garmin_sync.config import Settings
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
- mywhoosh_email="whoosh@example.com",
- mywhoosh_password="whoosh-pass",
garmin_email="garmin@example.com",
garmin_password="garmin-pass",
poll_interval_seconds=3600,
@@ -22,6 +20,8 @@ def settings(tmp_path: Path) -> Settings:
db_path=tmp_path / "state.sqlite3",
log_level="INFO",
dry_run=False,
+ dashboard_host="127.0.0.1",
+ dashboard_port=8080,
mywhoosh_login_url="https://www.mywhoosh.com/login/",
mywhoosh_activity_url="https://www.mywhoosh.com/profile/",
mywhoosh_headless=True,
diff --git a/tests/test_config.py b/tests/test_config.py
index a9f3018..017347f 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -19,6 +19,8 @@ def test_settings_from_env(monkeypatch, tmp_path):
assert settings.raw_dir == tmp_path / "raw"
assert settings.dry_run is True
assert settings.poll_interval_seconds == 123
+ assert settings.dashboard_host == "0.0.0.0"
+ assert settings.dashboard_port == 8080
assert settings.target_garmin_product_id == 3578
assert settings.target_garmin_serial_number == 123456
assert settings.mywhoosh_manual_login_wait_seconds == 900
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
new file mode 100644
index 0000000..36f059b
--- /dev/null
+++ b/tests/test_dashboard.py
@@ -0,0 +1,66 @@
+from mywhoosh_garmin_sync.dashboard import render_dashboard, render_health
+from mywhoosh_garmin_sync.state import ActivitySummary, SyncCheckSummary
+
+
+def test_dashboard_renders_latest_check_and_upload():
+ html = render_dashboard(
+ SyncCheckSummary(
+ id=1,
+ started_at="2026-05-07T10:00:00+00:00",
+ finished_at="2026-05-07T10:01:00+00:00",
+ status="ok",
+ message="OK: processed 1 activity.",
+ error_type=None,
+ downloaded_count=1,
+ processed_count=1,
+ failed_count=0,
+ ),
+ [
+ ActivitySummary(
+ source_ref="source-1",
+ title="Morning Ride",
+ source_url="https://example.test/ride.fit",
+ converted_path="/data/converted/ride.fit",
+ garmin_activity_id="12345",
+ uploaded_at="2026-05-07T10:01:00+00:00",
+ updated_at="2026-05-07T10:01:00+00:00",
+ )
+ ],
+ )
+
+ assert "OK: processed 1 activity." in html
+ assert "Morning Ride" in html
+ assert "12345" in html
+ assert "07.05.2026, 10:01 Uhr" in html
+ assert "/data/converted/ride.fit" in html
+ assert '' in html
+
+
+def test_dashboard_renders_empty_state_before_first_check():
+ html = render_dashboard(None, [])
+
+ assert "Never checked" in html
+ assert "No sync check has finished yet." in html
+ assert "No uploaded activities recorded yet." in html
+
+
+def test_health_renders_latest_status():
+ body = render_health(
+ SyncCheckSummary(
+ id=1,
+ started_at="2026-05-07T10:00:00+00:00",
+ finished_at="2026-05-07T10:01:00+00:00",
+ status="problem",
+ message="Garmin requested MFA",
+ error_type="garmin_login",
+ downloaded_count=1,
+ processed_count=1,
+ failed_count=1,
+ )
+ )
+
+ assert body == (
+ '{"status":"problem","message":"Garmin requested MFA",'
+ '"error_type":"garmin_login",'
+ '"finished_at":"2026-05-07T10:01:00+00:00"}'
+ )
diff --git a/tests/test_service.py b/tests/test_service.py
new file mode 100644
index 0000000..9b2d632
--- /dev/null
+++ b/tests/test_service.py
@@ -0,0 +1,95 @@
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from mywhoosh_garmin_sync.models import DownloadedActivity
+from mywhoosh_garmin_sync.service import ProcessingResult, SyncService
+from mywhoosh_garmin_sync.state import ActivityStore
+
+
+class FakeCrawler:
+ def __init__(self, downloads=None, exc: Exception | None = None):
+ self.downloads = downloads or []
+ self.exc = exc
+
+ async def download_new_activities(self, should_skip_source):
+ if self.exc is not None:
+ raise self.exc
+ return self.downloads
+
+
+class FakeUploader:
+ pass
+
+
+def test_run_once_records_ok_check_when_no_downloads(settings):
+ store = ActivityStore(settings.db_path)
+ service = SyncService(
+ settings,
+ crawler=FakeCrawler(),
+ uploader=FakeUploader(),
+ store=store,
+ )
+
+ asyncio.run(service.run_once())
+
+ latest = store.get_latest_sync_check()
+ assert latest is not None
+ assert latest.status == "ok"
+ assert latest.message == "OK: no new activities found."
+ assert latest.downloaded_count == 0
+ assert latest.failed_count == 0
+
+
+def test_run_once_records_mywhoosh_problem_before_downloads(settings):
+ store = ActivityStore(settings.db_path)
+ service = SyncService(
+ settings,
+ crawler=FakeCrawler(exc=RuntimeError("MyWhoosh login did not complete")),
+ uploader=FakeUploader(),
+ store=store,
+ )
+
+ with pytest.raises(RuntimeError):
+ asyncio.run(service.run_once())
+
+ latest = store.get_latest_sync_check()
+ assert latest is not None
+ assert latest.status == "problem"
+ assert latest.error_type == "mywhoosh_login"
+ assert latest.message == "MyWhoosh login did not complete"
+
+
+def test_run_once_records_activity_processing_problem(settings, tmp_path: Path):
+ raw_path = tmp_path / "activity.fit"
+ raw_path.write_bytes(b"not used")
+ activity = DownloadedActivity(
+ source_ref="source-1",
+ title="Ride",
+ url="https://example.test/ride.fit",
+ raw_path=raw_path,
+ )
+ store = ActivityStore(settings.db_path)
+ service = SyncService(
+ settings,
+ crawler=FakeCrawler(downloads=[activity]),
+ uploader=FakeUploader(),
+ store=store,
+ )
+ service._process_activity = lambda activity: ProcessingResult(
+ "failed",
+ "garmin_login",
+ "Garmin requested MFA but GARMIN_MFA_CODE is not set.",
+ )
+
+ asyncio.run(service.run_once())
+
+ latest = store.get_latest_sync_check()
+ assert latest is not None
+ assert latest.status == "problem"
+ assert latest.error_type == "garmin_login"
+ assert latest.downloaded_count == 1
+ assert latest.processed_count == 1
+ assert latest.failed_count == 1
+ assert "1 of 1 downloaded activities failed" in latest.message
diff --git a/tests/test_state.py b/tests/test_state.py
index f6eb740..1e923e2 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -42,3 +42,62 @@ def test_failed_activity_can_be_retried(tmp_path: Path):
assert store.get_status("source-1") == "failed"
assert store.is_terminal_source("source-1") is False
+
+def test_sync_check_summary_round_trip(tmp_path: Path):
+ store = ActivityStore(tmp_path / "state.sqlite3")
+ store.initialize()
+
+ check_id = store.begin_sync_check()
+ store.finish_sync_check(
+ check_id=check_id,
+ status="problem",
+ message="MyWhoosh login did not complete",
+ error_type="mywhoosh_login",
+ downloaded_count=2,
+ processed_count=1,
+ failed_count=1,
+ )
+
+ latest = store.get_latest_sync_check()
+
+ assert latest is not None
+ assert latest.id == check_id
+ assert latest.status == "problem"
+ assert latest.message == "MyWhoosh login did not complete"
+ assert latest.error_type == "mywhoosh_login"
+ assert latest.downloaded_count == 2
+ assert latest.processed_count == 1
+ assert latest.failed_count == 1
+ assert latest.finished_at is not None
+
+
+def test_recent_uploaded_activities_are_limited_and_ordered(tmp_path: Path):
+ store = ActivityStore(tmp_path / "state.sqlite3")
+ store.initialize()
+
+ for index in range(6):
+ source_ref = f"source-{index}"
+ store.record_downloaded(
+ source_ref=source_ref,
+ title=f"Ride {index}",
+ source_url=f"https://example.test/{index}",
+ raw_path=tmp_path / f"raw-{index}.fit",
+ raw_sha256=f"raw-hash-{index}",
+ )
+ store.mark_converted(
+ source_ref=source_ref,
+ converted_path=tmp_path / f"converted-{index}.fit",
+ converted_sha256=f"converted-hash-{index}",
+ patched_field_count=index,
+ )
+ store.mark_uploaded(source_ref, str(index))
+
+ recent = store.get_recent_uploaded_activities(5)
+
+ assert [activity.title for activity in recent] == [
+ "Ride 5",
+ "Ride 4",
+ "Ride 3",
+ "Ride 2",
+ "Ride 1",
+ ]