Dashboard eingefügt

This commit is contained in:
Bastian Wagner
2026-05-07 13:40:39 +02:00
parent e0ea9efe92
commit 3e743de61e
11 changed files with 889 additions and 34 deletions

View File

@@ -5,6 +5,8 @@ POLL_INTERVAL_SECONDS=3600
DATA_DIR=/data DATA_DIR=/data
LOG_LEVEL=INFO LOG_LEVEL=INFO
DRY_RUN=true DRY_RUN=true
DASHBOARD_HOST=0.0.0.0
DASHBOARD_PORT=8080
MYWHOOSH_LOGIN_URL=https://www.event.mywhoosh.com/login/ MYWHOOSH_LOGIN_URL=https://www.event.mywhoosh.com/login/
MYWHOOSH_ACTIVITY_URL=https://event.mywhoosh.com/user/activities MYWHOOSH_ACTIVITY_URL=https://event.mywhoosh.com/user/activities

View File

@@ -45,6 +45,14 @@ docker compose logs -f sync
The default polling interval is hourly. 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 ## Configuration
Required values: Required values:
@@ -61,6 +69,8 @@ POLL_INTERVAL_SECONDS=3600
DATA_DIR=/data DATA_DIR=/data
LOG_LEVEL=INFO LOG_LEVEL=INFO
DRY_RUN=false DRY_RUN=false
DASHBOARD_HOST=0.0.0.0
DASHBOARD_PORT=8080
MYWHOOSH_LOGIN_URL=https://www.mywhoosh.com/login/ MYWHOOSH_LOGIN_URL=https://www.mywhoosh.com/login/
MYWHOOSH_ACTIVITY_URL=https://www.mywhoosh.com/profile/ MYWHOOSH_ACTIVITY_URL=https://www.mywhoosh.com/profile/
MYWHOOSH_ACTIVITIES_BUTTON_TEXT=ACTIVITIES MYWHOOSH_ACTIVITIES_BUTTON_TEXT=ACTIVITIES

View File

@@ -57,6 +57,8 @@ class Settings:
db_path: Path db_path: Path
log_level: str log_level: str
dry_run: bool dry_run: bool
dashboard_host: str
dashboard_port: int
mywhoosh_login_url: str mywhoosh_login_url: str
mywhoosh_activity_url: str mywhoosh_activity_url: str
@@ -106,6 +108,8 @@ class Settings:
db_path=Path(os.getenv("STATE_DB", str(data_dir / "state.sqlite3"))), db_path=Path(os.getenv("STATE_DB", str(data_dir / "state.sqlite3"))),
log_level=os.getenv("LOG_LEVEL", "INFO").upper(), log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
dry_run=_bool_env("DRY_RUN", False), 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=os.getenv(
"MYWHOOSH_LOGIN_URL", "https://www.mywhoosh.com/login/" "MYWHOOSH_LOGIN_URL", "https://www.mywhoosh.com/login/"
), ),

View File

@@ -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 = """
<tr>
<td colspan="5" class="empty">No uploaded activities recorded yet.</td>
</tr>
"""
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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{refresh_seconds}">
<title>MyWhoosh Garmin Sync</title>
<style>
:root {{
color-scheme: light;
--bg: #f5f1e8;
--ink: #17211b;
--muted: #667166;
--panel: #fffaf0;
--line: #ded5c4;
--ok: #176b3a;
--warn: #a8451a;
--wait: #78633a;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
font-family: Georgia, "Times New Roman", serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(37, 89, 67, .18), transparent 28rem),
linear-gradient(135deg, #f7f0df 0%, #ebe4d6 100%);
}}
main {{
width: min(1080px, calc(100% - 32px));
margin: 0 auto;
padding: 40px 0;
}}
h1 {{
margin: 0 0 24px;
font-size: clamp(2rem, 5vw, 4.5rem);
line-height: .95;
letter-spacing: -.05em;
}}
.status {{
display: grid;
gap: 16px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 22px;
background: rgba(255, 250, 240, .82);
box-shadow: 0 24px 60px rgba(35, 27, 12, .12);
}}
.status-header {{
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}}
.badge {{
display: inline-flex;
align-items: center;
padding: 8px 13px;
border-radius: 999px;
font: 700 .78rem/1.1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: .08em;
text-transform: uppercase;
border: 1px solid currentColor;
}}
.badge.ok {{ color: var(--ok); }}
.badge.problem {{ color: var(--warn); }}
.badge.running {{ color: var(--wait); }}
.message {{
margin: 0;
font-size: 1.35rem;
}}
.meta {{
display: flex;
gap: 18px;
flex-wrap: wrap;
color: var(--muted);
font: .95rem/1.5 ui-sans-serif, system-ui, sans-serif;
}}
section {{
margin-top: 28px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--panel);
}}
h2 {{
margin: 0 0 18px;
font-size: 1.3rem;
}}
table {{
width: 100%;
border-collapse: collapse;
font: .95rem/1.45 ui-sans-serif, system-ui, sans-serif;
}}
th, td {{
padding: 13px 10px;
border-top: 1px solid var(--line);
text-align: left;
vertical-align: top;
}}
th {{
color: var(--muted);
font-size: .78rem;
letter-spacing: .08em;
text-transform: uppercase;
}}
a {{ color: #245b44; }}
.empty {{ color: var(--muted); text-align: center; }}
.path {{ color: var(--muted); overflow-wrap: anywhere; }}
@media (max-width: 760px) {{
main {{ padding-top: 24px; }}
section, .status {{ padding: 18px; }}
table, thead, tbody, tr, th, td {{ display: block; }}
thead {{ display: none; }}
tr {{ border-top: 1px solid var(--line); padding: 10px 0; }}
td {{ border: 0; padding: 5px 0; }}
td::before {{
content: attr(data-label);
display: block;
color: var(--muted);
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .08em;
}}
}}
</style>
</head>
<body>
<main>
<h1>MyWhoosh Garmin Sync</h1>
<div class="status">
<div class="status-header">
<span class="badge {status_class}">{escape(status_label)}</span>
<span class="meta">Auto-refreshes every {refresh_seconds}s</span>
</div>
<p class="message">{escape(message)}</p>
<div class="meta">
<span>Gestartet: {started_markup}</span>
<span>Beendet: {checked_markup}</span>
<span>{count_markup}</span>
{error_type_markup}
</div>
</div>
<section>
<h2>Last 5 Uploaded Activities</h2>
<table>
<thead>
<tr>
<th>Title</th>
<th>Uploaded</th>
<th>Garmin ID</th>
<th>Source</th>
<th>Converted File</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</section>
</main>
</body>
</html>
"""
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"<span>Problem type: {escape(latest_check.error_type)}</span>"
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"""
<tr>
<td data-label="Title">{title}</td>
<td data-label="Uploaded">{uploaded}</td>
<td data-label="Garmin ID">{garmin_id}</td>
<td data-label="Source">{source}</td>
<td data-label="Converted File" class="path">{converted}</td>
</tr>
"""
def _link(url: str, label: str) -> str:
safe_url = escape(url, quote=True)
return f'<a href="{safe_url}" rel="noreferrer">{escape(label)}</a>'
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")

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import signal import signal
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .config import Settings from .config import Settings
from .dashboard import DashboardServer
from .fit_device import GarminDevice, convert_fit_device from .fit_device import GarminDevice, convert_fit_device
from .garmin import GarminUploadBlocked, GarminUploader from .garmin import GarminUploadBlocked, GarminUploader
from .models import DownloadedActivity from .models import DownloadedActivity
@@ -15,6 +17,13 @@ from .state import ActivityStore, sha256_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProcessingResult:
status: str
error_type: str | None = None
message: str | None = None
class SyncService: class SyncService:
def __init__( def __init__(
self, self,
@@ -36,43 +45,102 @@ class SyncService:
async def serve(self) -> None: async def serve(self) -> None:
stop_event = asyncio.Event() stop_event = asyncio.Event()
dashboard = DashboardServer(
self.store,
host=self.settings.dashboard_host,
port=self.settings.dashboard_port,
)
dashboard.start()
try: 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: try:
await self.run_once() loop = asyncio.get_running_loop()
except Exception: for sig in (signal.SIGINT, signal.SIGTERM):
logger.exception("Sync cycle failed") loop.add_signal_handler(sig, stop_event.set)
except (NotImplementedError, RuntimeError):
pass
try: logger.info(
await asyncio.wait_for( "Starting sync loop; interval=%ss dry_run=%s",
stop_event.wait(), timeout=self.settings.poll_interval_seconds self.settings.poll_interval_seconds,
) self.settings.dry_run,
except TimeoutError: )
continue 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: async def run_once(self) -> None:
self.store.initialize() self.store.initialize()
downloads = await self.crawler.download_new_activities( check_id = self.store.begin_sync_check()
self.store.is_terminal_source downloads: list[DownloadedActivity] = []
) processed_count = 0
logger.info("Downloaded %d candidate activities", len(downloads)) 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: for activity in downloads:
await asyncio.to_thread(self._process_activity, activity) 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: try:
raw_hash = sha256_file(activity.raw_path) raw_hash = sha256_file(activity.raw_path)
self.store.record_downloaded( self.store.record_downloaded(
@@ -88,7 +156,7 @@ class SyncService:
activity.source_ref, activity.source_ref,
"Raw file hash was already uploaded from another source.", "Raw file hash was already uploaded from another source.",
) )
return return ProcessingResult("duplicate")
converted_path = self._converted_path(activity) converted_path = self._converted_path(activity)
result = convert_fit_device(activity.raw_path, converted_path, self.device) result = convert_fit_device(activity.raw_path, converted_path, self.device)
@@ -105,29 +173,67 @@ class SyncService:
activity.source_ref, activity.source_ref,
"Converted file hash was already uploaded from another source.", "Converted file hash was already uploaded from another source.",
) )
return return ProcessingResult("duplicate")
if self.settings.dry_run: if self.settings.dry_run:
logger.info("Dry run enabled; not uploading %s", converted_path) logger.info("Dry run enabled; not uploading %s", converted_path)
return return ProcessingResult("converted")
upload = self.uploader.upload(converted_path) upload = self.uploader.upload(converted_path)
if upload.duplicate: if upload.duplicate:
self.store.mark_duplicate( self.store.mark_duplicate(
activity.source_ref, "Garmin reported a duplicate activity." activity.source_ref, "Garmin reported a duplicate activity."
) )
return ProcessingResult("duplicate")
else: else:
self.store.mark_uploaded( self.store.mark_uploaded(
activity.source_ref, activity.source_ref,
garmin_activity_id=upload.garmin_activity_id, garmin_activity_id=upload.garmin_activity_id,
) )
return ProcessingResult("uploaded")
except GarminUploadBlocked as exc: except GarminUploadBlocked as exc:
logger.error("Upload blocked: %s", exc) logger.error("Upload blocked: %s", exc)
self.store.mark_failed(activity.source_ref, str(exc)) self.store.mark_failed(activity.source_ref, str(exc))
return ProcessingResult("failed", "garmin_login", str(exc))
except Exception as exc: except Exception as exc:
logger.exception("Failed processing %s", activity.raw_path) logger.exception("Failed processing %s", activity.raw_path)
self.store.mark_failed(activity.source_ref, str(exc)) 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: def _converted_path(self, activity: DownloadedActivity) -> Path:
return self.settings.converted_dir / activity.raw_path.name 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"

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import sqlite3 import sqlite3
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator
@@ -22,6 +23,30 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest() 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: class ActivityStore:
def __init__(self, db_path: Path) -> None: def __init__(self, db_path: Path) -> None:
self.db_path = db_path self.db_path = db_path
@@ -59,6 +84,124 @@ class ActivityStore:
ON activities(converted_sha256) 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: def is_terminal_source(self, source_ref: str) -> bool:
row = self._fetch_one( row = self._fetch_one(
@@ -216,4 +359,3 @@ class ActivityStore:
) -> sqlite3.Row | None: ) -> sqlite3.Row | None:
with self._connect() as conn: with self._connect() as conn:
return conn.execute(sql, params).fetchone() return conn.execute(sql, params).fetchone()

View File

@@ -8,8 +8,6 @@ from mywhoosh_garmin_sync.config import Settings
@pytest.fixture @pytest.fixture
def settings(tmp_path: Path) -> Settings: def settings(tmp_path: Path) -> Settings:
return Settings( return Settings(
mywhoosh_email="whoosh@example.com",
mywhoosh_password="whoosh-pass",
garmin_email="garmin@example.com", garmin_email="garmin@example.com",
garmin_password="garmin-pass", garmin_password="garmin-pass",
poll_interval_seconds=3600, poll_interval_seconds=3600,
@@ -22,6 +20,8 @@ def settings(tmp_path: Path) -> Settings:
db_path=tmp_path / "state.sqlite3", db_path=tmp_path / "state.sqlite3",
log_level="INFO", log_level="INFO",
dry_run=False, dry_run=False,
dashboard_host="127.0.0.1",
dashboard_port=8080,
mywhoosh_login_url="https://www.mywhoosh.com/login/", mywhoosh_login_url="https://www.mywhoosh.com/login/",
mywhoosh_activity_url="https://www.mywhoosh.com/profile/", mywhoosh_activity_url="https://www.mywhoosh.com/profile/",
mywhoosh_headless=True, mywhoosh_headless=True,

View File

@@ -19,6 +19,8 @@ def test_settings_from_env(monkeypatch, tmp_path):
assert settings.raw_dir == tmp_path / "raw" assert settings.raw_dir == tmp_path / "raw"
assert settings.dry_run is True assert settings.dry_run is True
assert settings.poll_interval_seconds == 123 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_product_id == 3578
assert settings.target_garmin_serial_number == 123456 assert settings.target_garmin_serial_number == 123456
assert settings.mywhoosh_manual_login_wait_seconds == 900 assert settings.mywhoosh_manual_login_wait_seconds == 900

66
tests/test_dashboard.py Normal file
View File

@@ -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 '<meta http-equiv="refresh" content="30">' 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"}'
)

95
tests/test_service.py Normal file
View File

@@ -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

View File

@@ -42,3 +42,62 @@ def test_failed_activity_can_be_retried(tmp_path: Path):
assert store.get_status("source-1") == "failed" assert store.get_status("source-1") == "failed"
assert store.is_terminal_source("source-1") is False 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",
]