Add Garmin Connect auto-sync via python-garminconnect
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 8s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:08:12 +01:00
parent 0cdc653664
commit 6d224d51c5
9 changed files with 691 additions and 3 deletions
+153
View File
@@ -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"}
+2 -1
View File
@@ -6,7 +6,7 @@ import asyncio
from app.core.database import engine, AsyncSessionLocal, Base from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings 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(): 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(records.router, prefix="/api/records", tags=["records"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) 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") @app.get("/health")
+20
View File
@@ -37,6 +37,26 @@ class User(Base):
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan") health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
named_routes = relationship("NamedRoute", 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") 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): class WeightLog(Base):
+255
View File
@@ -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
+86 -1
View File
@@ -22,6 +22,12 @@ celery_app.conf.update(
enable_utc=True, enable_utc=True,
task_track_started=True, task_track_started=True,
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
beat_schedule={
"sync-garmin-connect-hourly": {
"task": "sync_all_garmin_connect",
"schedule": 3600.0, # every hour
},
},
) )
WELLNESS_SUFFIXES = ( WELLNESS_SUFFIXES = (
@@ -46,7 +52,8 @@ def is_wellness_file(file_path: str) -> bool:
@celery_app.task(bind=True, name="process_activity_file") @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.""" """Parse a FIT/GPX file. Routes wellness files to health parser."""
if is_wellness_file(file_path): 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, user_id=user_id,
name=parsed["name"], name=parsed["name"],
sport_type=parsed["sport_type"], sport_type=parsed["sport_type"],
garmin_activity_id=garmin_activity_id,
start_time=start_time, start_time=start_time,
distance_m=parsed.get("distance_m"), distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"), duration_s=parsed.get("duration_s"),
@@ -450,6 +458,83 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
db.commit() 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") @celery_app.task(name="recalculate_hr_zones_for_user")
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float): 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.""" """Recalculate hr_zones for all of a user's activities using a new max HR."""
+3 -1
View File
@@ -22,4 +22,6 @@ Pillow==10.3.0
aiofiles==23.2.1 aiofiles==23.2.1
python-dateutil==2.9.0 python-dateutil==2.9.0
pytz==2024.1 pytz==2024.1
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
garminconnect==0.2.24
cryptography==42.0.8
+18
View File
@@ -91,6 +91,24 @@ services:
redis: redis:
condition: service_healthy 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: frontend:
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
container_name: milevault_frontend container_name: milevault_frontend
+20
View File
@@ -83,6 +83,26 @@ services:
redis: redis:
condition: service_healthy 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: frontend:
build: build:
context: ./frontend context: ./frontend
+134
View File
@@ -110,6 +110,54 @@ export default function ProfilePage() {
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'), 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 // PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' }) const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
const [pidSaved, setPidSaved] = useState(false) const [pidSaved, setPidSaved] = useState(false)
@@ -248,6 +296,92 @@ export default function ProfilePage() {
/> />
</Section> </Section>
{/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500">
Connect your Garmin account to automatically import new activities and wellness data every hour.
Credentials are encrypted at rest.
</p>
{garminConfig?.connected && (
<div className="flex items-center justify-between bg-green-900/20 border border-green-800/40 rounded-lg px-3 py-2 text-xs">
<span className="text-green-400"> Connected as {garminConfig.email}</span>
<div className="flex items-center gap-3">
{garminConfig.last_sync_at && (
<span className="text-gray-500">
Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
)}
{garminConfig.last_sync_status && (
<span className={garminConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : garminConfig.last_sync_status.startsWith('Auth') ? 'text-red-400' : 'text-yellow-400'}>
{garminConfig.last_sync_status}
</span>
)}
</div>
</div>
)}
<div className="space-y-3">
<Field label="Garmin Connect email">
<Input value={gcForm.email} placeholder="you@example.com"
onChange={e => setGcForm(f => ({ ...f, email: e.target.value }))} />
</Field>
<Field label={garminConfig?.connected ? 'Password (leave blank to keep existing)' : 'Password'}>
<Input type="password" value={gcForm.password} placeholder="••••••••"
onChange={e => setGcForm(f => ({ ...f, password: e.target.value }))} />
</Field>
<div className="flex flex-wrap gap-4 pt-1">
{[
['sync_enabled', 'Enable hourly sync'],
['sync_activities', 'Sync activities (FIT download)'],
['sync_wellness', 'Sync wellness data'],
].map(([key, label]) => (
<label key={key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={gcForm[key]}
onChange={e => setGcForm(f => ({ ...f, [key]: e.target.checked }))}
className="w-4 h-4 accent-blue-500" />
<span className="text-sm text-gray-300">{label}</span>
</label>
))}
</div>
</div>
{gcError && <p className="text-red-400 text-xs">{gcError}</p>}
<div className="flex items-center gap-3 flex-wrap pt-1">
<SaveButton
onClick={() => {
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 && (
<>
<button
onClick={triggerGarminSync}
disabled={gcSyncing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
</button>
<button
onClick={() => { if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
className="text-red-400 hover:text-red-300 text-sm transition-colors">
Disconnect
</button>
</>
)}
</div>
</Section>
{/* PocketID — admin only */} {/* PocketID — admin only */}
{user?.is_admin && ( {user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)"> <Section title="🔑 PocketID Passkey Authentication (Admin)">