From 6d224d51c5678c6ff6055aa821685c26f8a882d6 Mon Sep 17 00:00:00 2001 From: owain Date: Sun, 7 Jun 2026 00:08:12 +0100 Subject: [PATCH] Add Garmin Connect auto-sync via python-garminconnect - GarminConnectConfig model stores encrypted credentials and OAuth token - garmin_connect_sync service: token-based auth with password fallback, activity FIT download + queue, daily wellness from JSON API - Celery beat schedule: sync_all_garmin_connect fires every hour - New API router /api/garmin-sync: config CRUD, manual trigger - Beat container added to docker-compose.yml and docker-compose.deploy.yml - ProfilePage: Garmin Connect section with connect/update/disconnect and Sync now Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/garmin_sync.py | 153 ++++++++++++ backend/app/main.py | 3 +- backend/app/models/user.py | 20 ++ backend/app/services/garmin_connect_sync.py | 255 ++++++++++++++++++++ backend/app/workers/tasks.py | 87 ++++++- backend/requirements.txt | 4 +- docker-compose.deploy.yml | 18 ++ docker-compose.yml | 20 ++ frontend/src/pages/ProfilePage.jsx | 134 ++++++++++ 9 files changed, 691 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/garmin_sync.py create mode 100644 backend/app/services/garmin_connect_sync.py diff --git a/backend/app/api/garmin_sync.py b/backend/app/api/garmin_sync.py new file mode 100644 index 0000000..84345d2 --- /dev/null +++ b/backend/app/api/garmin_sync.py @@ -0,0 +1,153 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User, GarminConnectConfig + +router = APIRouter() + + +class GarminConfigIn(BaseModel): + email: str + password: str # plaintext; encrypted before storage + sync_enabled: bool = True + sync_activities: bool = True + sync_wellness: bool = True + + +class GarminConfigOut(BaseModel): + email: str + sync_enabled: bool + sync_activities: bool + sync_wellness: bool + last_sync_at: Optional[datetime] + last_sync_status: Optional[str] + connected: bool # True when credentials exist + + class Config: + from_attributes = True + + +@router.get("/config", response_model=GarminConfigOut) +async def get_config( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) + ) + cfg = result.scalar_one_or_none() + if not cfg: + return GarminConfigOut( + email="", sync_enabled=False, sync_activities=True, + sync_wellness=True, last_sync_at=None, last_sync_status=None, + connected=False, + ) + return GarminConfigOut( + email=cfg.email, + sync_enabled=cfg.sync_enabled, + sync_activities=cfg.sync_activities, + sync_wellness=cfg.sync_wellness, + last_sync_at=cfg.last_sync_at, + last_sync_status=cfg.last_sync_status, + connected=True, + ) + + +@router.put("/config", response_model=GarminConfigOut) +async def save_config( + body: GarminConfigIn, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Save (or replace) Garmin Connect credentials. + Attempts a test login first so we can store the initial OAuth token and + fail fast on wrong credentials. + """ + from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin + + enc = encrypt_password(body.password) + + # Test login — raises HTTPException on auth failure + try: + garmin, token_store = authenticate_garmin(body.email, enc, None) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}") + + result = await db.execute( + select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) + ) + cfg = result.scalar_one_or_none() + + if cfg: + cfg.email = body.email + cfg.password_enc = enc + cfg.token_store = token_store + cfg.sync_enabled = body.sync_enabled + cfg.sync_activities = body.sync_activities + cfg.sync_wellness = body.sync_wellness + cfg.last_sync_status = "Credentials updated" + else: + cfg = GarminConnectConfig( + user_id=current_user.id, + email=body.email, + password_enc=enc, + token_store=token_store, + sync_enabled=body.sync_enabled, + sync_activities=body.sync_activities, + sync_wellness=body.sync_wellness, + last_sync_status="Connected", + ) + db.add(cfg) + + await db.commit() + await db.refresh(cfg) + + return GarminConfigOut( + email=cfg.email, + sync_enabled=cfg.sync_enabled, + sync_activities=cfg.sync_activities, + sync_wellness=cfg.sync_wellness, + last_sync_at=cfg.last_sync_at, + last_sync_status=cfg.last_sync_status, + connected=True, + ) + + +@router.delete("/config") +async def delete_config( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) + ) + cfg = result.scalar_one_or_none() + if cfg: + await db.delete(cfg) + await db.commit() + return {"status": "ok"} + + +@router.post("/trigger") +async def trigger_sync( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Enqueue an immediate Garmin Connect sync for this user.""" + result = await db.execute( + select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) + ) + cfg = result.scalar_one_or_none() + if not cfg or not cfg.sync_enabled: + raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled") + + from app.workers.tasks import sync_garmin_connect_user + task = sync_garmin_connect_user.delay(current_user.id) + return {"task_id": task.id, "status": "queued"} diff --git a/backend/app/main.py b/backend/app/main.py index c1a9204..c2f3933 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ import asyncio from app.core.database import engine, AsyncSessionLocal, Base from app.core.config import settings -from app.api import auth, activities, routes, health, records, upload, profile +from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync async def init_db(): @@ -98,6 +98,7 @@ app.include_router(health.router, prefix="/api/health-metrics", tags=["health"]) app.include_router(records.router, prefix="/api/records", tags=["records"]) app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) +app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"]) @app.get("/health") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8275b29..4feeaca 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -37,6 +37,26 @@ class User(Base): health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan") named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan") weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan") + garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan") + + +class GarminConnectConfig(Base): + """Per-user Garmin Connect credentials and sync state.""" + __tablename__ = "garmin_connect_configs" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True) + email = Column(String(256), nullable=False) + password_enc = Column(String(512), nullable=False) # Fernet-encrypted + token_store = Column(Text, nullable=True) # garth OAuth2 token JSON + sync_enabled = Column(Boolean, default=True) + sync_activities = Column(Boolean, default=True) + sync_wellness = Column(Boolean, default=True) + last_sync_at = Column(DateTime(timezone=True), nullable=True) + last_sync_status = Column(String(512), nullable=True) + created_at = Column(DateTime(timezone=True), default=now_utc) + + user = relationship("User", back_populates="garmin_connect_config") class WeightLog(Base): diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py new file mode 100644 index 0000000..a1f9bb6 --- /dev/null +++ b/backend/app/services/garmin_connect_sync.py @@ -0,0 +1,255 @@ +""" +Garmin Connect sync helpers. + +authenticate_garmin() returns an authenticated client, refreshing the stored +OAuth token when possible and falling back to email/password re-login. + +sync_activities() downloads new FIT files and queues them for processing. +sync_wellness() pulls daily stats/sleep/HRV summaries from the JSON API +and upserts them into health_metrics. +""" +import io +import zipfile +import logging +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + + +# ── Password encryption ───────────────────────────────────────────────────── + +def _fernet(): + import base64, hashlib + from cryptography.fernet import Fernet + from app.core.config import settings + key = base64.urlsafe_b64encode(hashlib.sha256(settings.secret_key.encode()).digest()) + return Fernet(key) + + +def encrypt_password(password: str) -> str: + return _fernet().encrypt(password.encode()).decode() + + +def decrypt_password(enc: str) -> str: + return _fernet().decrypt(enc.encode()).decode() + + +# ── Auth ───────────────────────────────────────────────────────────────────── + +def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str]) -> Tuple: + """ + Returns (garmin_client, new_token_store_or_None). + new_token_store is set only when tokens were refreshed/re-created so the + caller can persist them. + """ + import garminconnect + + # Try stored OAuth token first (garth auto-refreshes access token on use) + if token_store: + try: + garmin = garminconnect.Garmin( + email=email, password=decrypt_password(password_enc) + ) + garmin.garth.loads(token_store) + garmin.get_full_name() # lightweight request; triggers refresh if needed + return garmin, None # tokens still valid + except Exception as exc: + logger.info("Garmin token invalid (%s), re-authenticating", exc) + + # Full login with email + password + garmin = garminconnect.Garmin(email=email, password=decrypt_password(password_enc)) + garmin.login() + return garmin, garmin.garth.dumps() + + +# ── Activity sync ───────────────────────────────────────────────────────────── + +def sync_activities(garmin, user_id: int, since: Optional[datetime], + db, file_store_path: str) -> int: + """ + List activities since `since` from Garmin Connect, skip any already in the + DB, download FIT ZIPs for new ones, and queue them for processing. + Returns the number of new activities queued. + """ + from app.workers.tasks import process_activity_file + from app.models.user import Activity + from sqlalchemy import select + + start_date = (since - timedelta(days=1)).date() if since else (date.today() - timedelta(days=30)) + end_date = date.today() + + try: + activities = garmin.get_activities_by_date( + start_date.isoformat(), end_date.isoformat() + ) + except Exception as exc: + logger.error("Failed to list Garmin activities: %s", exc) + return 0 + + queued = 0 + for act in activities: + garmin_id = str(act.get("activityId", "")).strip() + if not garmin_id: + continue + + # Skip if already imported (garmin_activity_id unique index) + existing = db.execute( + select(Activity).where(Activity.garmin_activity_id == garmin_id) + ).scalar_one_or_none() + if existing: + continue + + # Download original FIT (wrapped in a ZIP by Garmin) + try: + zip_bytes = garmin.download_activity( + int(garmin_id), + dl_fmt=garmin.ActivityDownloadFormat.ORIGINAL, + ) + except Exception as exc: + logger.warning("Failed to download activity %s: %s", garmin_id, exc) + continue + + # Extract the FIT file from the ZIP + try: + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")] + if not fit_names: + logger.debug("No FIT in ZIP for activity %s", garmin_id) + continue + fit_data = zf.read(fit_names[0]) + except Exception as exc: + logger.warning("Failed to unzip activity %s: %s", garmin_id, exc) + continue + + # Save to disk and queue + dest_dir = Path(file_store_path) / str(user_id) / "garmin_connect" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / f"{garmin_id}.fit" + dest.write_bytes(fit_data) + + process_activity_file.delay(str(dest), user_id, "fit", garmin_id) + queued += 1 + + return queued + + +# ── Wellness sync ───────────────────────────────────────────────────────────── + +def sync_wellness(garmin, user_id: int, since: Optional[datetime], db) -> int: + """ + Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each + day since `since` and upsert into health_metrics. + Returns the number of days upserted. + """ + from sqlalchemy import text + + start_date = since.date() if since else (date.today() - timedelta(days=7)) + days = (date.today() - start_date).days + 1 + processed = 0 + + for i in range(max(days, 1)): + day = start_date + timedelta(days=i) + day_str = day.isoformat() + + stats = _safe(garmin.get_stats, day_str) + sleep_data = _safe(garmin.get_sleep_data, day_str) + hrv_data = _safe(garmin.get_hrv_data, day_str) + + row = _parse_day(stats, sleep_data, hrv_data) + if not row: + continue + + cols = list(row.keys()) + col_sql = ", ".join(cols) + val_sql = ", ".join(f":{c}" for c in cols) + upd_sql = ", ".join( + # total_calories uses GREATEST so multiple sources don't downgrade + f"{c} = GREATEST(EXCLUDED.{c}, health_metrics.{c})" + if c == "total_calories" else + f"{c} = COALESCE(EXCLUDED.{c}, health_metrics.{c})" + for c in cols + ) + + params = {"user_id": user_id, "day": day.isoformat()} + params.update(row) + + db.execute(text(f""" + INSERT INTO health_metrics (user_id, date, {col_sql}) + VALUES (:user_id, :day, {val_sql}) + ON CONFLICT (user_id, date) DO UPDATE SET {upd_sql} + """), params) + db.commit() + processed += 1 + + return processed + + +def _safe(fn, *args): + try: + return fn(*args) + except Exception as exc: + logger.debug("%s(%s) skipped: %s", fn.__name__, args, exc) + return None + + +def _parse_day(stats, sleep_data, hrv_data) -> dict: + row = {} + + if stats: + _set(row, "resting_hr", stats.get("restingHeartRate")) + _set(row, "steps", stats.get("totalSteps")) + _set(row, "floors_climbed", stats.get("floorsAscended")) + _set(row, "avg_stress", stats.get("averageStressLevel")) + active = stats.get("activeKilocalories") + bmr = stats.get("bmrKilocalories") + _set(row, "active_calories", active) + if active and bmr: + _set(row, "total_calories", float(active) + float(bmr)) + + if sleep_data: + dto = sleep_data.get("dailySleepDTO") or sleep_data + _set(row, "sleep_duration_s", dto.get("sleepTimeSeconds")) + _set(row, "sleep_deep_s", dto.get("deepSleepSeconds")) + _set(row, "sleep_light_s", dto.get("lightSleepSeconds")) + _set(row, "sleep_rem_s", dto.get("remSleepSeconds")) + _set(row, "sleep_awake_s", dto.get("awakeSleepSeconds")) + + # Timestamps are milliseconds since epoch in local time + for key, col in (("sleepStartTimestampLocal", "sleep_start"), + ("sleepEndTimestampLocal", "sleep_end")): + ms = dto.get(key) + if ms: + _set(row, col, datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()) + + # SpO2 + spo2 = dto.get("averageSpO2Value") + if spo2 and 50 < float(spo2) <= 100: + row["spo2_avg"] = float(spo2) + + # Sleep score — structure varies across firmware + scores = sleep_data.get("sleepScores") or sleep_data.get("sleepScore") + if isinstance(scores, dict): + overall = scores.get("overall") or scores.get("qualityScore") + if isinstance(overall, dict): + _set(row, "sleep_score", overall.get("value")) + else: + _set(row, "sleep_score", overall) + elif isinstance(scores, (int, float)): + row["sleep_score"] = scores + + if hrv_data: + summary = hrv_data.get("hrvSummary") or hrv_data + _set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg")) + _set(row, "hrv_5min_high", summary.get("lastNight5MinHigh")) + status = summary.get("status") + if status: + row["hrv_status"] = str(status).lower() + + return row + + +def _set(d: dict, key: str, val): + if val is not None: + d[key] = val diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 7d5f491..afc0ae9 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -22,6 +22,12 @@ celery_app.conf.update( enable_utc=True, task_track_started=True, worker_prefetch_multiplier=1, + beat_schedule={ + "sync-garmin-connect-hourly": { + "task": "sync_all_garmin_connect", + "schedule": 3600.0, # every hour + }, + }, ) WELLNESS_SUFFIXES = ( @@ -46,7 +52,8 @@ def is_wellness_file(file_path: str) -> bool: @celery_app.task(bind=True, name="process_activity_file") -def process_activity_file(self, file_path: str, user_id: int, source_type: str): +def process_activity_file(self, file_path: str, user_id: int, source_type: str, + garmin_activity_id: str = None): """Parse a FIT/GPX file. Routes wellness files to health parser.""" if is_wellness_file(file_path): @@ -110,6 +117,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str): user_id=user_id, name=parsed["name"], sport_type=parsed["sport_type"], + garmin_activity_id=garmin_activity_id, start_time=start_time, distance_m=parsed.get("distance_m"), duration_s=parsed.get("duration_s"), @@ -450,6 +458,83 @@ def process_garmin_health_zip(zip_path: str, user_id: int): db.commit() +@celery_app.task(name="sync_garmin_connect_user") +def sync_garmin_connect_user(user_id: int): + """Sync Garmin Connect data (activities + wellness) for one user.""" + from app.services.garmin_connect_sync import authenticate_garmin, sync_activities, sync_wellness + from app.core.database import SyncSessionLocal + from app.models.user import GarminConnectConfig + from app.core.config import settings + from sqlalchemy import select + from datetime import datetime, timezone + + with SyncSessionLocal() as db: + cfg = db.execute( + select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id) + ).scalar_one_or_none() + + if not cfg or not cfg.sync_enabled: + return {"status": "skipped"} + + try: + garmin, new_token = authenticate_garmin(cfg.email, cfg.password_enc, cfg.token_store) + except Exception as exc: + cfg.last_sync_at = datetime.now(timezone.utc) + cfg.last_sync_status = f"Auth error: {exc}" + db.commit() + return {"status": "auth_error", "error": str(exc)} + + if new_token: + cfg.token_store = new_token + + activities_queued = 0 + wellness_days = 0 + errors = [] + + if cfg.sync_activities: + try: + activities_queued = sync_activities( + garmin, user_id, cfg.last_sync_at, db, settings.file_store_path + ) + except Exception as exc: + errors.append(f"activities: {exc}") + + if cfg.sync_wellness: + try: + wellness_days = sync_wellness(garmin, user_id, cfg.last_sync_at, db) + except Exception as exc: + errors.append(f"wellness: {exc}") + + cfg.last_sync_at = datetime.now(timezone.utc) + cfg.last_sync_status = ( + f"OK — {activities_queued} activities queued, {wellness_days} wellness days synced" + if not errors else + f"Partial — {'; '.join(errors)}" + ) + db.commit() + + return {"status": "ok", "activities_queued": activities_queued, "wellness_days": wellness_days} + + +@celery_app.task(name="sync_all_garmin_connect") +def sync_all_garmin_connect(): + """Hourly beat task: dispatch per-user sync for all enabled configs.""" + from app.core.database import SyncSessionLocal + from app.models.user import GarminConnectConfig + from sqlalchemy import select + + with SyncSessionLocal() as db: + configs = db.execute( + select(GarminConnectConfig).where(GarminConnectConfig.sync_enabled == True) + ).scalars().all() + user_ids = [c.user_id for c in configs] + + for uid in user_ids: + sync_garmin_connect_user.delay(uid) + + return {"dispatched": len(user_ids)} + + @celery_app.task(name="recalculate_hr_zones_for_user") def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float): """Recalculate hr_zones for all of a user's activities using a new max HR.""" diff --git a/backend/requirements.txt b/backend/requirements.txt index 3c9c905..bdb173d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,4 +22,6 @@ Pillow==10.3.0 aiofiles==23.2.1 python-dateutil==2.9.0 pytz==2024.1 -psycopg2-binary==2.9.9 \ No newline at end of file +psycopg2-binary==2.9.9 +garminconnect==0.2.24 +cryptography==42.0.8 \ No newline at end of file diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 3ee1378..330e1ad 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -91,6 +91,24 @@ services: redis: condition: service_healthy + beat: + image: gitea.yourdomain.com/yourusername/milevault-worker:latest + container_name: milevault_beat + restart: unless-stopped + command: celery -A app.workers.celery_app beat --loglevel=info + environment: + DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault + REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32} + FILE_STORE_PATH: /data/files + volumes: + - file_data:/data/files + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + frontend: image: gitea.yourdomain.com/yourusername/milevault-frontend:latest container_name: milevault_frontend diff --git a/docker-compose.yml b/docker-compose.yml index 96cfa88..069a296 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,26 @@ services: redis: condition: service_healthy + beat: + build: + context: ./backend + dockerfile: Dockerfile.worker + container_name: milevault_beat + restart: unless-stopped + command: celery -A app.workers.celery_app beat --loglevel=info + environment: + DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault + REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars} + FILE_STORE_PATH: /data/files + volumes: + - file_data:/data/files + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + frontend: build: context: ./frontend diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index 801b645..43e0e20 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -110,6 +110,54 @@ export default function ProfilePage() { onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'), }) + // Garmin Connect sync + const { data: garminConfig, refetch: refetchGarmin } = useQuery({ + queryKey: ['garmin-config'], + queryFn: () => api.get('/garmin-sync/config').then(r => r.data), + }) + const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true }) + const [gcSaved, setGcSaved] = useState(false) + const [gcError, setGcError] = useState('') + const [gcSyncing, setGcSyncing] = useState(false) + useEffect(() => { + if (garminConfig?.connected) { + setGcForm(f => ({ + ...f, + email: garminConfig.email || '', + sync_enabled: garminConfig.sync_enabled, + sync_activities: garminConfig.sync_activities, + sync_wellness: garminConfig.sync_wellness, + })) + } + }, [garminConfig]) + const saveGarmin = useMutation({ + mutationFn: data => api.put('/garmin-sync/config', data).then(r => r.data), + onSuccess: () => { + refetchGarmin() + setGcSaved(true) + setGcError('') + setGcForm(f => ({ ...f, password: '' })) + setTimeout(() => setGcSaved(false), 3000) + }, + onError: e => setGcError(e.response?.data?.detail || 'Failed to save'), + }) + const deleteGarmin = useMutation({ + mutationFn: () => api.delete('/garmin-sync/config'), + onSuccess: () => { + refetchGarmin() + setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true }) + }, + }) + const triggerGarminSync = async () => { + setGcSyncing(true) + try { + await api.post('/garmin-sync/trigger') + setTimeout(() => { refetchGarmin(); setGcSyncing(false) }, 3000) + } catch (e) { + setGcSyncing(false) + } + } + // PocketID config const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' }) const [pidSaved, setPidSaved] = useState(false) @@ -248,6 +296,92 @@ export default function ProfilePage() { /> + {/* Garmin Connect Sync */} +
+

+ Connect your Garmin account to automatically import new activities and wellness data every hour. + Credentials are encrypted at rest. +

+ + {garminConfig?.connected && ( +
+ ✓ Connected as {garminConfig.email} +
+ {garminConfig.last_sync_at && ( + + Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} + + )} + {garminConfig.last_sync_status && ( + + {garminConfig.last_sync_status} + + )} +
+
+ )} + +
+ + setGcForm(f => ({ ...f, email: e.target.value }))} /> + + + setGcForm(f => ({ ...f, password: e.target.value }))} /> + + +
+ {[ + ['sync_enabled', 'Enable hourly sync'], + ['sync_activities', 'Sync activities (FIT download)'], + ['sync_wellness', 'Sync wellness data'], + ].map(([key, label]) => ( + + ))} +
+
+ + {gcError &&

{gcError}

} + +
+ { + if (!garminConfig?.connected && !gcForm.password) { + setGcError('Password is required for first-time setup') + return + } + const payload = { ...gcForm } + if (!payload.password) delete payload.password + saveGarmin.mutate(payload) + }} + loading={saveGarmin.isPending} + saved={gcSaved} + label={garminConfig?.connected ? 'Update' : 'Connect'} + /> + {garminConfig?.connected && ( + <> + + + + )} +
+
+ {/* PocketID — admin only */} {user?.is_admin && (