diff --git a/backend/app/api/strava_sync.py b/backend/app/api/strava_sync.py new file mode 100644 index 0000000..529b7f5 --- /dev/null +++ b/backend/app/api/strava_sync.py @@ -0,0 +1,271 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from typing import Optional +from datetime import datetime, timezone, timedelta +from urllib.parse import urlencode + +from jose import jwt, JWTError + +from app.core.database import get_db +from app.core.security import get_current_user, create_access_token +from app.core.config import settings +from app.models.user import User, StravaConfig +from app.services.strava_sync import ( + STRAVA_AUTHORIZE_URL, STRAVA_SCOPE, exchange_code, encrypt_token, +) + +router = APIRouter() + +STATE_PURPOSE = "strava-link" + + +def _redis_client(): + import redis as redis_lib + return redis_lib.Redis.from_url(settings.redis_url) + + +def sync_task_key(user_id: int) -> str: + return f"strava_sync_task:{user_id}" + + +def sync_cancel_key(user_id: int) -> str: + return f"strava_sync_cancel:{user_id}" + + +def _redirect_uri() -> str: + return f"{settings.base_url.rstrip('/')}/api/strava-sync/callback" + + +class StravaConfigIn(BaseModel): + sync_enabled: bool = True + sync_lookback_days: int = 30 + + +class StravaConfigOut(BaseModel): + connected: bool + athlete_name: Optional[str] = None + sync_enabled: bool = False + sync_lookback_days: int = 30 + sync_interval_minutes: int = settings.garmin_sync_interval_minutes + last_sync_at: Optional[datetime] = None + last_sync_status: Optional[str] = None + configured: bool = True # whether the server has Strava API credentials at all + + +def _out(cfg: Optional[StravaConfig]) -> StravaConfigOut: + configured = bool(settings.strava_client_id and settings.strava_client_secret) + if not cfg: + return StravaConfigOut( + connected=False, sync_enabled=False, sync_lookback_days=30, + sync_interval_minutes=settings.garmin_sync_interval_minutes, + configured=configured, + ) + return StravaConfigOut( + connected=True, + athlete_name=cfg.athlete_name, + sync_enabled=cfg.sync_enabled, + sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30, + sync_interval_minutes=settings.garmin_sync_interval_minutes, + last_sync_at=cfg.last_sync_at, + last_sync_status=cfg.last_sync_status, + configured=configured, + ) + + +@router.get("/config", response_model=StravaConfigOut) +async def get_config( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + cfg = (await db.execute( + select(StravaConfig).where(StravaConfig.user_id == current_user.id) + )).scalar_one_or_none() + return _out(cfg) + + +@router.get("/authorize") +async def authorize(current_user: User = Depends(get_current_user)): + """Return the Strava OAuth URL to redirect the browser to. A signed `state` + carries the user id through the callback (which has no JWT header).""" + if not (settings.strava_client_id and settings.strava_client_secret): + raise HTTPException(status_code=400, detail="Strava API is not configured on this server") + state = create_access_token( + {"sub": str(current_user.id), "purpose": STATE_PURPOSE}, + expires_delta=timedelta(minutes=10), + ) + params = { + "client_id": settings.strava_client_id, + "response_type": "code", + "redirect_uri": _redirect_uri(), + "approval_prompt": "auto", + "scope": STRAVA_SCOPE, + "state": state, + } + return {"url": f"{STRAVA_AUTHORIZE_URL}?{urlencode(params)}"} + + +@router.get("/callback") +async def callback( + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + scope: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + """Strava redirects here after the user authorizes. Exchange the code, store + tokens, then bounce back to the SPA profile page.""" + profile_url = f"{settings.base_url.rstrip('/')}/profile" + + if error or not code: + return RedirectResponse(f"{profile_url}?strava=error") + + # Validate state → user id + try: + payload = jwt.decode(state or "", settings.secret_key, algorithms=[settings.algorithm]) + if payload.get("purpose") != STATE_PURPOSE: + raise ValueError("bad purpose") + user_id = int(payload["sub"]) + except (JWTError, KeyError, TypeError, ValueError): + return RedirectResponse(f"{profile_url}?strava=error") + + # Require activity:read_all so private activities sync too. + if scope and "activity:read_all" not in scope: + return RedirectResponse(f"{profile_url}?strava=scope") + + try: + tok = exchange_code(code) + except Exception: + return RedirectResponse(f"{profile_url}?strava=error") + + athlete = tok.get("athlete") or {} + athlete_name = " ".join( + x for x in [athlete.get("firstname"), athlete.get("lastname")] if x + ).strip() or (athlete.get("username") or None) + + cfg = (await db.execute( + select(StravaConfig).where(StravaConfig.user_id == user_id) + )).scalar_one_or_none() + + expires_at = datetime.fromtimestamp(tok["expires_at"], tz=timezone.utc) + if cfg: + cfg.access_token_enc = encrypt_token(tok["access_token"]) + cfg.refresh_token_enc = encrypt_token(tok["refresh_token"]) + cfg.expires_at = expires_at + cfg.athlete_id = str(athlete.get("id") or "") or cfg.athlete_id + cfg.athlete_name = athlete_name or cfg.athlete_name + cfg.last_sync_status = "Connected" + else: + cfg = StravaConfig( + user_id=user_id, + athlete_id=str(athlete.get("id") or "") or None, + athlete_name=athlete_name, + access_token_enc=encrypt_token(tok["access_token"]), + refresh_token_enc=encrypt_token(tok["refresh_token"]), + expires_at=expires_at, + sync_enabled=True, + sync_lookback_days=30, + last_sync_status="Connected", + ) + db.add(cfg) + await db.commit() + + # Kick off an initial sync immediately. + try: + from app.workers.tasks import sync_strava_user + sync_strava_user.delay(user_id) + except Exception: + pass + + return RedirectResponse(f"{profile_url}?strava=connected") + + +@router.put("/config", response_model=StravaConfigOut) +async def save_config( + body: StravaConfigIn, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + cfg = (await db.execute( + select(StravaConfig).where(StravaConfig.user_id == current_user.id) + )).scalar_one_or_none() + if not cfg: + raise HTTPException(status_code=400, detail="Strava is not connected") + + # Asking for more history than before → reset last_sync_at so the next sync + # backfills the wider window (mirrors the Garmin behaviour). + old = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30 + new = body.sync_lookback_days + wants_more = (new != old) and (new == -1 or (old != -1 and new > old)) + if wants_more: + cfg.last_sync_at = None + cfg.last_sync_status = "Lookback increased — backfill on next sync" + + cfg.sync_enabled = body.sync_enabled + cfg.sync_lookback_days = body.sync_lookback_days + await db.commit() + await db.refresh(cfg) + return _out(cfg) + + +@router.delete("/config") +async def delete_config( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + cfg = (await db.execute( + select(StravaConfig).where(StravaConfig.user_id == current_user.id) + )).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), +): + cfg = (await db.execute( + select(StravaConfig).where(StravaConfig.user_id == current_user.id) + )).scalar_one_or_none() + if not cfg or not cfg.sync_enabled: + raise HTTPException(status_code=400, detail="Strava sync is not configured or disabled") + + from app.workers.tasks import sync_strava_user + task = sync_strava_user.delay(current_user.id) + try: + r = _redis_client() + r.delete(sync_cancel_key(current_user.id)) + r.set(sync_task_key(current_user.id), task.id, ex=3600) + except Exception: + pass + return {"task_id": task.id, "status": "queued"} + + +@router.post("/cancel") +async def cancel_sync( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + from app.workers.tasks import celery_app + try: + r = _redis_client() + r.set(sync_cancel_key(current_user.id), "1", ex=3600) + task_id = r.get(sync_task_key(current_user.id)) + if task_id: + tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id + celery_app.control.revoke(tid, terminate=False) + except Exception: + pass + + cfg = (await db.execute( + select(StravaConfig).where(StravaConfig.user_id == current_user.id) + )).scalar_one_or_none() + if cfg: + cfg.last_sync_status = "Cancelling…" + await db.commit() + return {"status": "cancelling"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 6caf851..f9b4c80 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -74,6 +74,33 @@ def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]: return extracted +def _gunzip(path: Path) -> Path | None: + """Decompress a .gz member to a sibling file without the .gz suffix, + enforcing the same uncompressed-size cap. Returns the new path, or None on + failure. The .gz is removed once expanded.""" + import gzip + out_path = path.with_suffix("") # strips the trailing .gz + try: + total = 0 + with gzip.open(path, "rb") as src, open(out_path, "wb") as out: + while True: + chunk = src.read(_CHUNK) + if not chunk: + break + total += len(chunk) + if total > MAX_EXTRACT_SIZE: + out.close() + out_path.unlink(missing_ok=True) + return None + out.write(chunk) + except (OSError, EOFError): + out_path.unlink(missing_ok=True) + return None + finally: + path.unlink(missing_ok=True) + return out_path + + @router.post("/activity") async def upload_activity( file: UploadFile = File(...), @@ -81,10 +108,10 @@ async def upload_activity( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Upload a single .fit or .gpx activity file.""" + """Upload a single .fit, .gpx or .tcx activity file.""" suffix = Path(file.filename).suffix.lower() - if suffix not in {".fit", ".gpx"}: - raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported") + if suffix not in {".fit", ".gpx", ".tcx"}: + raise HTTPException(status_code=400, detail="Only .fit, .gpx and .tcx files are supported") dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities" dest = save_upload(file, dest_dir) @@ -184,15 +211,24 @@ async def upload_strava_export( raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive") for path in extracted: + # Strava compresses most exported activities as .fit.gz / .gpx.gz / + # .tcx.gz. Decompress those to their underlying file first, otherwise the + # gzipped majority of the archive is silently skipped. + if path.suffix.lower() == ".gz": + inner = _gunzip(path) + if inner is None: + continue + path = inner + suffix = path.suffix.lower() - if suffix in (".fit", ".gpx"): + if suffix in (".fit", ".gpx", ".tcx"): task = process_activity_file.delay(str(path), current_user.id, suffix[1:]) task_ids.append(task.id) if not task_ids: raise HTTPException( status_code=400, - detail="No activity files (.fit or .gpx) found in this Strava archive", + detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive", ) return { diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 23652f9..adb304f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -24,6 +24,10 @@ class Settings(BaseSettings): pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP") # Garmin Connect — how often the beat scheduler runs the automatic sync garmin_sync_interval_minutes: int = Field(30, env="GARMIN_SYNC_INTERVAL_MINUTES") + # Strava API (optional) — register an app at https://www.strava.com/settings/api. + # The Authorization Callback Domain there must match BASE_URL's host. + strava_client_id: Optional[str] = Field(None, env="STRAVA_CLIENT_ID") + strava_client_secret: Optional[str] = Field(None, env="STRAVA_CLIENT_SECRET") # Files file_store_path: str = Field("/data/files", env="FILE_STORE_PATH") # Environment diff --git a/backend/app/main.py b/backend/app/main.py index b001dff..fac75fb 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, garmin_sync, users, segments +from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, strava_sync, users, segments async def init_db(): @@ -260,6 +260,7 @@ 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.include_router(strava_sync.router, prefix="/api/strava-sync", tags=["strava-sync"]) app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(segments.router, prefix="/api/segments", tags=["segments"]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2ab78c2..6e2a902 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -45,6 +45,7 @@ class User(Base): 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") + strava_config = relationship("StravaConfig", back_populates="user", uselist=False, cascade="all, delete-orphan") class GarminConnectConfig(Base): @@ -67,6 +68,27 @@ class GarminConnectConfig(Base): user = relationship("User", back_populates="garmin_connect_config") +class StravaConfig(Base): + """Per-user Strava OAuth tokens and sync state. Tokens are Fernet-encrypted + (same SECRET_KEY scheme as Garmin credentials).""" + __tablename__ = "strava_configs" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True) + athlete_id = Column(String(64), nullable=True) + athlete_name = Column(String(256), nullable=True) + access_token_enc = Column(String(512), nullable=False) + refresh_token_enc = Column(String(512), nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=True) # access-token expiry + sync_enabled = Column(Boolean, default=True) + sync_lookback_days = Column(Integer, default=30) # -1 = all-time, first sync only + 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="strava_config") + + class WeightLog(Base): """Manual weight entries separate from health_metrics for easy tracking.""" __tablename__ = "weight_logs" diff --git a/backend/app/services/fit_parser.py b/backend/app/services/fit_parser.py index 51cafc2..a9f3b82 100644 --- a/backend/app/services/fit_parser.py +++ b/backend/app/services/fit_parser.py @@ -406,6 +406,143 @@ def parse_gpx_file(filepath: str) -> dict: } +def parse_tcx_file(filepath: str) -> dict: + """Parse a Garmin Training Center XML (.tcx) activity file. + + Strava bulk exports include older device uploads as .tcx (often gzipped). + TCX is namespaced XML; we match by local tag name so the parser is robust to + the various TCX namespace declarations in the wild. Output mirrors + parse_gpx_file so downstream ingestion is identical.""" + import xml.etree.ElementTree as ET + + def local(tag: str) -> str: + return tag.split("}")[-1] if "}" in tag else tag + + def find(el, name): + for child in el.iter(): + if local(child.tag) == name: + return child + return None + + def findall(el, name): + return [c for c in el.iter() if local(c.tag) == name] + + def child_text(el, name): + for c in list(el): + if local(c.tag) == name and c.text: + return c.text.strip() + return None + + tree = ET.parse(filepath) + root = tree.getroot() + + activity_el = find(root, "Activity") + sport_raw = (activity_el.get("Sport") if activity_el is not None else None) or "Other" + sport = {"running": "running", "biking": "cycling", + "walking": "walking", "hiking": "hiking"}.get(sport_raw.lower(), sport_raw.lower()) + + data_points = [] + for tp in findall(root, "Trackpoint"): + ts_str = child_text(tp, "Time") + ts = None + if ts_str: + try: + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + except ValueError: + ts = None + + lat = lng = None + pos = next((c for c in list(tp) if local(c.tag) == "Position"), None) + if pos is not None: + lat = _safe_float(child_text(pos, "LatitudeDegrees")) + lng = _safe_float(child_text(pos, "LongitudeDegrees")) + + hr = None + hr_el = next((c for c in list(tp) if local(c.tag) == "HeartRateBpm"), None) + if hr_el is not None: + hr = _safe_float(child_text(hr_el, "Value")) + + # Speed/Watts live in a TPX extension; search descendants by local name. + speed = watts = None + for ext in findall(tp, "Speed"): + speed = _safe_float(ext.text) + break + for ext in findall(tp, "Watts"): + watts = _safe_float(ext.text) + break + + data_points.append({ + "timestamp": ts.isoformat() if ts else None, + "latitude": lat, "longitude": lng, + "altitude_m": _safe_float(child_text(tp, "AltitudeMeters")), + "heart_rate": hr, + "cadence": _safe_float(child_text(tp, "Cadence")), + "speed_ms": speed, + "power": watts, + "temperature_c": None, + "distance_m": _safe_float(child_text(tp, "DistanceMeters")), + }) + + coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]] + encoded_polyline = polyline_lib.encode(coords) if coords else None + bounding_box = _bounding_box(coords) + + # Distance: prefer the cumulative DistanceMeters from the file; fall back to + # haversine over GPS points when absent (some TCX trackpoints omit it). + dist_vals = [p["distance_m"] for p in data_points if p["distance_m"] is not None] + if dist_vals: + total_dist = max(dist_vals) + else: + total_dist = 0.0 + prev = None + for p in data_points: + if p["latitude"] and p["longitude"]: + if prev: + R = 6371000 + phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"]) + dphi = math.radians(p["latitude"] - prev[0]) + dlam = math.radians(p["longitude"] - prev[1]) + a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2 + total_dist += 2 * R * math.asin(math.sqrt(a)) + prev = (p["latitude"], p["longitude"]) + p["distance_m"] = total_dist + + uphill, downhill = 0.0, 0.0 + alts = [p["altitude_m"] for p in data_points if p["altitude_m"] is not None] + for i in range(1, len(alts)): + diff = alts[i] - alts[i-1] + if diff > 0: uphill += diff + else: downhill += abs(diff) + + hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]] + start_time_str = next((p["timestamp"] for p in data_points if p["timestamp"]), None) + last_time_str = next((p["timestamp"] for p in reversed(data_points) if p["timestamp"]), None) + start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None + end_dt = datetime.fromisoformat(last_time_str) if last_time_str else None + duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None + avg_speed = (total_dist / duration) if (total_dist and duration) else None + + return { + "name": f"{sport.title()} {start_dt.date() if start_dt else ''}".strip(), + "sport_type": sport, "start_time": start_time_str, + "distance_m": total_dist or None, "duration_s": duration, "moving_time_s": None, + "elevation_gain_m": uphill, "elevation_loss_m": downhill, + "avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None, + "max_heart_rate": max(hrs) if hrs else None, + "avg_cadence": None, "avg_power": None, "normalized_power": None, + "avg_speed_ms": avg_speed, + "max_speed_ms": None, "avg_temperature_c": None, "calories": None, + "training_stress_score": None, "vo2max_estimate": None, + "polyline": encoded_polyline, "bounding_box": bounding_box, + "active_spans": _active_spans(data_points), + "source_type": "tcx", + "rejected_reason": _vehicle_reason(sport, avg_speed, total_dist, duration), + "data_points": data_points, "laps": [], + } + + def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict: if not user_max_hr or user_max_hr < 100: return {} diff --git a/backend/app/services/strava_sync.py b/backend/app/services/strava_sync.py new file mode 100644 index 0000000..a1d659e --- /dev/null +++ b/backend/app/services/strava_sync.py @@ -0,0 +1,290 @@ +""" +Strava API sync helpers. + +OAuth: exchange_code() / refresh_tokens() talk to Strava's token endpoint. +get_valid_access_token() transparently refreshes an expired access token and +persists the rotated refresh token. + +sync_strava_activities() lists the athlete's activities, fetches per-activity +streams, builds a fit_parser-shaped dict and hands it to persist_activity() +(shared with the file-upload path) so Strava activities get identical dedup, +PR, route and segment handling. + +Tokens are Fernet-encrypted with the same SECRET_KEY scheme as Garmin creds. +""" +import logging +from datetime import date, datetime, timedelta, timezone +from typing import Optional + +import httpx +import polyline as polyline_lib + +from app.services.fit_parser import _bounding_box, _active_spans +from app.services.garmin_connect_sync import _fernet # reuse the SECRET_KEY-derived Fernet + +logger = logging.getLogger(__name__) + +STRAVA_AUTHORIZE_URL = "https://www.strava.com/oauth/authorize" +STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token" +STRAVA_API_BASE = "https://www.strava.com/api/v3" + +# Scope needed to read all activities (including those marked "Only You"). +STRAVA_SCOPE = "read,activity:read_all" + +# Like Garmin: incremental syncs only re-scan the last day or two for late edits. +INCREMENTAL_BUFFER_DAYS = 1 + +# Strava activity type / sport_type → MileVault internal sport_type. Gym types map +# onto the internal vocabulary so sportColor() paints them red, GPS types green/etc. +STRAVA_SPORT_MAP = { + "run": "running", "trailrun": "running", "virtualrun": "running", + "ride": "cycling", "virtualride": "cycling", "mountainbikeride": "cycling", + "gravelride": "cycling", "ebikeride": "cycling", "emountainbikeride": "cycling", + "handcycle": "cycling", "velomobile": "cycling", + "walk": "walking", "hike": "hiking", + "swim": "swimming", + "weighttraining": "strength_training", "workout": "training", + "crossfit": "hiit", "hiit": "hiit", + "elliptical": "fitness_equipment", "stairstepper": "fitness_equipment", + "golf": "golf", "yoga": "yoga", +} + + +def encrypt_token(token: str) -> str: + return _fernet().encrypt(token.encode()).decode() + + +def decrypt_token(enc: str) -> str: + return _fernet().decrypt(enc.encode()).decode() + + +# ── OAuth ────────────────────────────────────────────────────────────────────── + +def _token_request(payload: dict) -> dict: + from app.core.config import settings + payload = { + "client_id": settings.strava_client_id, + "client_secret": settings.strava_client_secret, + **payload, + } + with httpx.Client(timeout=30) as client: + resp = client.post(STRAVA_TOKEN_URL, data=payload) + resp.raise_for_status() + return resp.json() + + +def exchange_code(code: str) -> dict: + """Exchange an authorization code for tokens. Returns the raw token dict + (access_token, refresh_token, expires_at, athlete{...}).""" + return _token_request({"code": code, "grant_type": "authorization_code"}) + + +def refresh_tokens(refresh_token: str) -> dict: + """Get a fresh access token (and possibly rotated refresh token).""" + return _token_request({"refresh_token": refresh_token, "grant_type": "refresh_token"}) + + +def get_valid_access_token(cfg, db) -> str: + """Return a usable access token for `cfg`, refreshing and persisting if the + current one is expired or about to expire. `cfg` is a StravaConfig row.""" + now = datetime.now(timezone.utc) + expires_at = cfg.expires_at + if expires_at is not None and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + if expires_at is None or expires_at <= now + timedelta(seconds=60): + tok = refresh_tokens(decrypt_token(cfg.refresh_token_enc)) + cfg.access_token_enc = encrypt_token(tok["access_token"]) + cfg.refresh_token_enc = encrypt_token(tok["refresh_token"]) + cfg.expires_at = datetime.fromtimestamp(tok["expires_at"], tz=timezone.utc) + db.commit() + return tok["access_token"] + + return decrypt_token(cfg.access_token_enc) + + +# ── Activity sync ────────────────────────────────────────────────────────────── + +def _api_get(client: httpx.Client, token: str, path: str, **params): + resp = client.get( + f"{STRAVA_API_BASE}{path}", + headers={"Authorization": f"Bearer {token}"}, + params=params, + ) + if resp.status_code == 429: + raise StravaRateLimited() + resp.raise_for_status() + return resp.json() + + +class StravaRateLimited(Exception): + """Raised when Strava returns HTTP 429 (rate limit exceeded).""" + + +def _streams_to_points(streams: dict, start_dt: datetime) -> list: + """Convert Strava's key_by_type streams into fit_parser-shaped data points.""" + time_s = (streams.get("time") or {}).get("data") or [] + if not time_s: + return [] + latlng = (streams.get("latlng") or {}).get("data") or [] + altitude = (streams.get("altitude") or {}).get("data") or [] + heartrate = (streams.get("heartrate") or {}).get("data") or [] + cadence = (streams.get("cadence") or {}).get("data") or [] + watts = (streams.get("watts") or {}).get("data") or [] + velocity = (streams.get("velocity_smooth") or {}).get("data") or [] + temp = (streams.get("temp") or {}).get("data") or [] + distance = (streams.get("distance") or {}).get("data") or [] + + def at(arr, i): + return arr[i] if i < len(arr) else None + + points = [] + for i, off in enumerate(time_s): + ll = at(latlng, i) + lat = ll[0] if ll else None + lng = ll[1] if ll else None + points.append({ + "timestamp": (start_dt + timedelta(seconds=off)).isoformat(), + "latitude": lat, "longitude": lng, + "altitude_m": at(altitude, i), + "heart_rate": at(heartrate, i), + "cadence": at(cadence, i), + "speed_ms": at(velocity, i), + "power": at(watts, i), + "temperature_c": at(temp, i), + "distance_m": at(distance, i), + }) + return points + + +def _build_parsed(summary: dict, points: list) -> dict: + """Assemble a fit_parser-shaped dict from a Strava summary + stream points.""" + raw_type = (summary.get("sport_type") or summary.get("type") or "workout") + sport = STRAVA_SPORT_MAP.get(str(raw_type).lower(), str(raw_type).lower()) + + start_str = summary.get("start_date") # UTC ISO, e.g. 2020-01-01T08:00:00Z + start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) if start_str else None + + coords = [(p["latitude"], p["longitude"]) for p in points if p["latitude"] and p["longitude"]] + encoded = polyline_lib.encode(coords) if coords else (summary.get("map") or {}).get("summary_polyline") + + # Elevation loss from the altitude stream (summary only gives gain). + alts = [p["altitude_m"] for p in points if p["altitude_m"] is not None] + downhill = sum(max(0.0, alts[i-1] - alts[i]) for i in range(1, len(alts))) if alts else None + + return { + "name": summary.get("name") or f"{sport.title()} {start_dt.date() if start_dt else ''}".strip(), + "sport_type": sport, + "start_time": start_dt.isoformat() if start_dt else None, + "distance_m": summary.get("distance"), + "duration_s": summary.get("elapsed_time"), + "moving_time_s": summary.get("moving_time"), + "elevation_gain_m": summary.get("total_elevation_gain"), + "elevation_loss_m": downhill, + "avg_heart_rate": summary.get("average_heartrate"), + "max_heart_rate": summary.get("max_heartrate"), + "avg_cadence": summary.get("average_cadence"), + "avg_power": summary.get("average_watts"), + "normalized_power": summary.get("weighted_average_watts"), + "avg_speed_ms": summary.get("average_speed"), + "max_speed_ms": summary.get("max_speed"), + "active_spans": _active_spans(points), + "avg_temperature_c": summary.get("average_temp"), + "calories": summary.get("calories"), + "training_stress_score": None, + "vo2max_estimate": None, + "polyline": encoded, + "bounding_box": _bounding_box(coords), + "source_type": "strava", + "rejected_reason": None, + "data_points": points, + "laps": [], + } + + +def sync_strava_activities(cfg, user_id: int, db, lookback_days: int = 30, + status_callback=None) -> int: + """List the athlete's activities since the appropriate window, fetch streams + for each new one and persist it. Returns the number of activities imported.""" + import time as _time + from app.workers.tasks import persist_activity + + since = cfg.last_sync_at + if since is not None and since.tzinfo is None: + since = since.replace(tzinfo=timezone.utc) + + if since: + after_dt = since - timedelta(days=INCREMENTAL_BUFFER_DAYS) + elif lookback_days == -1: + after_dt = datetime(2010, 1, 1, tzinfo=timezone.utc) + else: + after_dt = datetime.now(timezone.utc) - timedelta(days=max(lookback_days, 1)) + after_epoch = int(after_dt.timestamp()) + + token = get_valid_access_token(cfg, db) + imported = 0 + + with httpx.Client(timeout=60) as client: + # 1) Page through activity summaries (newest first within each page). + summaries = [] + page = 1 + while True: + batch = _api_get(client, token, "/athlete/activities", + after=after_epoch, page=page, per_page=100) + if not batch: + break + summaries.extend(batch) + if len(batch) < 100: + break + page += 1 + _time.sleep(0.3) + + total = len(summaries) + if status_callback: + status_callback(f"Syncing activities: 0/{total} imported") + + # 2) For each, fetch streams and persist. Oldest-first so PRs accrue in order. + for idx, summary in enumerate(sorted(summaries, key=lambda s: s.get("start_date") or "")): + sid = str(summary.get("id") or "").strip() + if not sid: + continue + + points = [] + try: + streams = _api_get( + client, token, f"/activities/{sid}/streams", + keys="time,latlng,altitude,heartrate,cadence,watts,velocity_smooth,temp,distance", + key_by_type="true", + ) + start_str = summary.get("start_date") + start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) if start_str else None + if start_dt: + points = _streams_to_points(streams, start_dt) + except StravaRateLimited: + # Out of quota — stop here; the next scheduled sync resumes the rest. + if status_callback: + status_callback(f"Rate-limited by Strava — imported {imported}, will resume next sync") + logger.warning("Strava rate limit hit for user %s after %d imports", user_id, imported) + break + except Exception as exc: + logger.warning("Failed to fetch streams for Strava activity %s: %s", sid, exc) + # Still persist from the summary (map polyline only, no per-point data). + + parsed = _build_parsed(summary, points) + if not parsed.get("start_time"): + continue + + try: + result = persist_activity(db, user_id, parsed, strava_activity_id=sid) + if result.get("status") == "ok": + imported += 1 + except Exception as exc: + logger.warning("Failed to persist Strava activity %s: %s", sid, exc) + db.rollback() + + if status_callback and (idx % 5 == 0 or idx == total - 1): + status_callback(f"Syncing activities: {imported}/{total} imported") + + _time.sleep(0.3) # be gentle on the rate limit + + return imported diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index d39d720..611ae85 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -28,6 +28,12 @@ celery_app.conf.update( # Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min) "schedule": float(settings.garmin_sync_interval_minutes * 60), }, + "sync-strava": { + "task": "sync_all_strava", + # Shares the Garmin cadence setting; Strava's rate limits make a + # tighter interval unwise anyway. + "schedule": float(settings.garmin_sync_interval_minutes * 60), + }, }, ) @@ -94,7 +100,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str, parse_wellness_fit.delay(file_path, user_id) return {"status": "routed_to_wellness", "file": file_path} - from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones + from app.services.fit_parser import parse_fit_file, parse_gpx_file, parse_tcx_file, calculate_hr_zones from app.core.database import SyncSessionLocal from app.models.user import Activity, ActivityDataPoint, ActivityLap from sqlalchemy import select, func @@ -105,6 +111,8 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str, try: if source_type == "fit" or file_path.endswith(".fit"): parsed = parse_fit_file(file_path) + elif source_type == "tcx" or file_path.endswith(".tcx"): + parsed = parse_tcx_file(file_path) else: parsed = parse_gpx_file(file_path) except Exception as e: @@ -122,123 +130,155 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str, return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path} with SyncSessionLocal() as db: - start_time = datetime.fromisoformat(parsed["start_time"]) + return persist_activity( + db, user_id, parsed, + source_file=file_path, + garmin_activity_id=garmin_activity_id, + ) - # Deduplicate: same user + sport_type + start_time within ±60s - from datetime import timedelta + +def persist_activity(db, user_id: int, parsed: dict, *, source_file: str = None, + garmin_activity_id: str = None, strava_activity_id: str = None) -> dict: + """Insert a parsed activity (+ data points, laps) and dispatch the + PR/route/segment follow-up tasks. Shared by the file-upload path + (process_activity_file) and the Strava API sync, so both get identical + dedup, HR-zone and downstream behaviour. `parsed` is the fit_parser-shaped + dict. Returns {"activity_id", "status"}.""" + from app.models.user import Activity, ActivityDataPoint, ActivityLap, User as UserModel + from app.services.fit_parser import calculate_hr_zones + from sqlalchemy import select + from datetime import datetime, timedelta + + start_time = datetime.fromisoformat(parsed["start_time"]) + + # Fast-path dedup for re-syncs: same external id already imported. + if strava_activity_id: existing = db.execute( - select(Activity).where( - Activity.user_id == user_id, - Activity.sport_type == parsed["sport_type"], - Activity.start_time >= start_time - timedelta(seconds=60), - Activity.start_time <= start_time + timedelta(seconds=60), - ) - ).scalars().first() - + select(Activity).where(Activity.strava_activity_id == str(strava_activity_id)) + ).scalar_one_or_none() if existing: - # Stamp garmin_activity_id if this came from a Garmin Connect sync - # so future syncs skip the fast-path dedup and don't re-download. - if garmin_activity_id and not existing.garmin_activity_id: - existing.garmin_activity_id = garmin_activity_id - db.commit() return {"activity_id": existing.id, "status": "duplicate"} - # Get user max HR for zone calculation - from app.models.user import User as UserModel - user_obj = db.execute( - select(UserModel).where(UserModel.id == user_id) - ).scalar_one_or_none() - - user_max_hr = None - if user_obj: - user_max_hr = user_obj.max_heart_rate - if not user_max_hr and user_obj.birth_year: - from datetime import date as _date - age = _date.today().year - user_obj.birth_year - user_max_hr = 220 - age - if not user_max_hr: - user_max_hr = parsed.get("max_heart_rate") or 190 - - hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr) - - activity = Activity( - 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"), - moving_time_s=parsed.get("moving_time_s"), - elevation_gain_m=parsed.get("elevation_gain_m"), - elevation_loss_m=parsed.get("elevation_loss_m"), - avg_heart_rate=parsed.get("avg_heart_rate"), - max_heart_rate=parsed.get("max_heart_rate"), - avg_cadence=parsed.get("avg_cadence"), - avg_power=parsed.get("avg_power"), - normalized_power=parsed.get("normalized_power"), - avg_speed_ms=parsed.get("avg_speed_ms"), - max_speed_ms=parsed.get("max_speed_ms"), - active_spans=parsed.get("active_spans"), - avg_temperature_c=parsed.get("avg_temperature_c"), - calories=parsed.get("calories"), - training_stress_score=parsed.get("training_stress_score"), - polyline=parsed.get("polyline"), - bounding_box=parsed.get("bounding_box"), - source_file=file_path, - source_type=parsed.get("source_type"), - hr_zones=hr_zones, + # Deduplicate across sources: same user + sport_type + start_time within ±60s. + # This also collapses an activity synced from both Garmin and Strava into one. + existing = db.execute( + select(Activity).where( + Activity.user_id == user_id, + Activity.sport_type == parsed["sport_type"], + Activity.start_time >= start_time - timedelta(seconds=60), + Activity.start_time <= start_time + timedelta(seconds=60), ) - db.add(activity) - db.flush() + ).scalars().first() - seen = set() - batch = [] - for p in parsed.get("data_points", []): - if not p.get("timestamp"): - continue - ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"] - key = (activity.id, ts) - if key in seen: - continue - seen.add(key) - batch.append(ActivityDataPoint( - activity_id=activity.id, - timestamp=ts, - latitude=p.get("latitude"), - longitude=p.get("longitude"), - altitude_m=p.get("altitude_m"), - heart_rate=p.get("heart_rate"), - cadence=p.get("cadence"), - speed_ms=p.get("speed_ms"), - power=p.get("power"), - temperature_c=p.get("temperature_c"), - distance_m=p.get("distance_m"), - )) - if len(batch) >= 500: - db.add_all(batch) - db.flush() - batch = [] - if batch: + if existing: + # Stamp the external id so future syncs skip straight to the fast path. + stamped = False + if garmin_activity_id and not existing.garmin_activity_id: + existing.garmin_activity_id = garmin_activity_id + stamped = True + if strava_activity_id and not existing.strava_activity_id: + existing.strava_activity_id = str(strava_activity_id) + stamped = True + if stamped: + db.commit() + return {"activity_id": existing.id, "status": "duplicate"} + + # Get user max HR for zone calculation + user_obj = db.execute( + select(UserModel).where(UserModel.id == user_id) + ).scalar_one_or_none() + + user_max_hr = None + if user_obj: + user_max_hr = user_obj.max_heart_rate + if not user_max_hr and user_obj.birth_year: + from datetime import date as _date + age = _date.today().year - user_obj.birth_year + user_max_hr = 220 - age + if not user_max_hr: + user_max_hr = parsed.get("max_heart_rate") or 190 + + hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr) + + activity = Activity( + user_id=user_id, + name=parsed["name"], + sport_type=parsed["sport_type"], + garmin_activity_id=garmin_activity_id, + strava_activity_id=str(strava_activity_id) if strava_activity_id else None, + start_time=start_time, + distance_m=parsed.get("distance_m"), + duration_s=parsed.get("duration_s"), + moving_time_s=parsed.get("moving_time_s"), + elevation_gain_m=parsed.get("elevation_gain_m"), + elevation_loss_m=parsed.get("elevation_loss_m"), + avg_heart_rate=parsed.get("avg_heart_rate"), + max_heart_rate=parsed.get("max_heart_rate"), + avg_cadence=parsed.get("avg_cadence"), + avg_power=parsed.get("avg_power"), + normalized_power=parsed.get("normalized_power"), + avg_speed_ms=parsed.get("avg_speed_ms"), + max_speed_ms=parsed.get("max_speed_ms"), + active_spans=parsed.get("active_spans"), + avg_temperature_c=parsed.get("avg_temperature_c"), + calories=parsed.get("calories"), + training_stress_score=parsed.get("training_stress_score"), + polyline=parsed.get("polyline"), + bounding_box=parsed.get("bounding_box"), + source_file=source_file, + source_type=parsed.get("source_type"), + hr_zones=hr_zones, + ) + db.add(activity) + db.flush() + + seen = set() + batch = [] + for p in parsed.get("data_points", []): + if not p.get("timestamp"): + continue + ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"] + key = (activity.id, ts) + if key in seen: + continue + seen.add(key) + batch.append(ActivityDataPoint( + activity_id=activity.id, + timestamp=ts, + latitude=p.get("latitude"), + longitude=p.get("longitude"), + altitude_m=p.get("altitude_m"), + heart_rate=p.get("heart_rate"), + cadence=p.get("cadence"), + speed_ms=p.get("speed_ms"), + power=p.get("power"), + temperature_c=p.get("temperature_c"), + distance_m=p.get("distance_m"), + )) + if len(batch) >= 500: db.add_all(batch) db.flush() + batch = [] + if batch: + db.add_all(batch) + db.flush() - for lap in parsed.get("laps", []): - ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None - db.add(ActivityLap( - activity_id=activity.id, - lap_number=lap["lap_number"], - start_time=ls, - duration_s=lap.get("duration_s"), - distance_m=lap.get("distance_m"), - avg_heart_rate=lap.get("avg_heart_rate"), - avg_cadence=lap.get("avg_cadence"), - avg_speed_ms=lap.get("avg_speed_ms"), - avg_power=lap.get("avg_power"), - )) + for lap in parsed.get("laps", []): + ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None + db.add(ActivityLap( + activity_id=activity.id, + lap_number=lap["lap_number"], + start_time=ls, + duration_s=lap.get("duration_s"), + distance_m=lap.get("distance_m"), + avg_heart_rate=lap.get("avg_heart_rate"), + avg_cadence=lap.get("avg_cadence"), + avg_speed_ms=lap.get("avg_speed_ms"), + avg_power=lap.get("avg_power"), + )) - db.commit() - activity_id = activity.id + db.commit() + activity_id = activity.id compute_personal_records.delay(activity_id, user_id, parsed) if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"): @@ -841,6 +881,96 @@ def sync_all_garmin_connect(): return {"dispatched": len(user_ids)} +@celery_app.task(name="sync_strava_user") +def sync_strava_user(user_id: int): + """Sync activities from Strava for one user via the Strava API.""" + from app.services.strava_sync import sync_strava_activities + from app.core.database import SyncSessionLocal + from app.models.user import StravaConfig + from app.core.config import settings + from sqlalchemy import select + from datetime import datetime, timezone + + cancel_key = f"strava_sync_cancel:{user_id}" + try: + import redis as redis_lib + _redis = redis_lib.Redis.from_url(settings.redis_url) + except Exception: + _redis = None + + def _cancelled(): + try: + return bool(_redis and _redis.exists(cancel_key)) + except Exception: + return False + + with SyncSessionLocal() as db: + cfg = db.execute( + select(StravaConfig).where(StravaConfig.user_id == user_id) + ).scalar_one_or_none() + if not cfg or not cfg.sync_enabled: + return {"status": "skipped"} + + lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30 + + cfg.last_sync_status = "Connecting to Strava..." + db.commit() + + def _set_status(text): + if _cancelled(): + raise SyncCancelled() + cfg.last_sync_status = text + db.commit() + + try: + imported = sync_strava_activities( + cfg, user_id, db, lookback_days=lookback, status_callback=_set_status, + ) + except SyncCancelled: + db.rollback() + cfg.last_sync_at = datetime.now(timezone.utc) + cfg.last_sync_status = "Cancelled" + db.commit() + try: + if _redis: + _redis.delete(cancel_key) + except Exception: + pass + return {"status": "cancelled"} + except Exception as exc: + db.rollback() + cfg.last_sync_at = datetime.now(timezone.utc) + msg = str(exc) + cfg.last_sync_status = f"Auth error: {msg}" if "401" in msg or "auth" in msg.lower() else f"Error: {msg}" + db.commit() + return {"status": "error", "error": msg} + + cfg.last_sync_at = datetime.now(timezone.utc) + cfg.last_sync_status = f"OK — {imported} activities imported" + db.commit() + + return {"status": "ok", "activities_imported": imported} + + +@celery_app.task(name="sync_all_strava") +def sync_all_strava(): + """Beat task: dispatch a per-user Strava sync for all enabled configs.""" + from app.core.database import SyncSessionLocal + from app.models.user import StravaConfig + from sqlalchemy import select + + with SyncSessionLocal() as db: + configs = db.execute( + select(StravaConfig).where(StravaConfig.sync_enabled == True) + ).scalars().all() + user_ids = [c.user_id for c in configs] + + for uid in user_ids: + sync_strava_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/docker-compose.deploy.yml b/docker-compose.deploy.yml index 330e1ad..c08f138 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -58,6 +58,9 @@ services: POCKETID_ISSUER: ${POCKETID_ISSUER:-} POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-} POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-} + STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-} + STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-} + BASE_URL: ${BASE_URL:-https://milevault.jarrett.eu} FILE_STORE_PATH: /data/files ENVIRONMENT: production volumes: @@ -82,6 +85,8 @@ services: 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} + STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-} + STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-} FILE_STORE_PATH: /data/files volumes: - file_data:/data/files @@ -100,6 +105,8 @@ services: 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} + STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-} + STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-} FILE_STORE_PATH: /data/files volumes: - file_data:/data/files diff --git a/docker-compose.yml b/docker-compose.yml index 97ed2fa..c81a5db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,9 @@ services: POCKETID_ISSUER: ${POCKETID_ISSUER:-} POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-} POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-} + STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-} + STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-} + BASE_URL: ${BASE_URL:-https://milevault.jarrett.eu} FILE_STORE_PATH: /data/files ENVIRONMENT: ${ENVIRONMENT:-production} volumes: @@ -74,6 +77,8 @@ services: 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} + STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-} + STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-} FILE_STORE_PATH: /data/files volumes: - ./file_data:/data/files @@ -94,6 +99,8 @@ services: 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} + STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-} + STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-} FILE_STORE_PATH: /data/files volumes: - ./file_data:/data/files diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index 0dfa2a0..b71060b 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -175,6 +175,65 @@ export default function ProfilePage() { setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' }) }, }) + // Strava sync + const { data: stravaConfig, refetch: refetchStrava } = useQuery({ + queryKey: ['strava-config'], + queryFn: () => api.get('/strava-sync/config').then(r => r.data), + // Poll while a sync is running so the status text stays live. + refetchInterval: q => { + const s = q.state.data?.last_sync_status || '' + const running = s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s) + return running ? 3000 : false + }, + }) + const [stForm, setStForm] = useState({ sync_enabled: true, sync_lookback_days: '30' }) + const [stSaved, setStSaved] = useState(false) + const stFormLoaded = useRef(false) + useEffect(() => { + if (stravaConfig?.connected && !stFormLoaded.current) { + stFormLoaded.current = true + setStForm({ + sync_enabled: stravaConfig.sync_enabled, + sync_lookback_days: String(stravaConfig.sync_lookback_days ?? 30), + }) + } else if (!stravaConfig?.connected) { + stFormLoaded.current = false + } + }, [stravaConfig]) + // OAuth return banner (?strava=connected|error|scope), then strip the query. + const [stReturn, setStReturn] = useState('') + useEffect(() => { + const p = new URLSearchParams(window.location.search) + const s = p.get('strava') + if (s) { + setStReturn(s) + refetchStrava() + p.delete('strava') + const qs = p.toString() + window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '')) + } + }, []) + const connectStrava = useMutation({ + mutationFn: () => api.get('/strava-sync/authorize').then(r => r.data), + onSuccess: d => { if (d?.url) window.location.href = d.url }, + }) + const saveStrava = useMutation({ + mutationFn: data => api.put('/strava-sync/config', data).then(r => r.data), + onSuccess: () => { refetchStrava(); setStSaved(true); setTimeout(() => setStSaved(false), 3000) }, + }) + const triggerStrava = useMutation({ + mutationFn: () => api.post('/strava-sync/trigger'), + onSuccess: () => setTimeout(refetchStrava, 1000), + }) + const deleteStrava = useMutation({ + mutationFn: () => api.delete('/strava-sync/config'), + onSuccess: () => { refetchStrava(); setStForm({ sync_enabled: true, sync_lookback_days: '30' }) }, + }) + const stravaSyncing = (() => { + const s = stravaConfig?.last_sync_status || '' + return !!s && !/^(OK|Error|Auth error|Connected|Cancelled|Lookback)/.test(s) + })() + // PocketID config const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' }) const [pidSaved, setPidSaved] = useState(false) @@ -445,6 +504,94 @@ export default function ProfilePage() { })()} + {/* Strava Sync */} +
+

+ Connect your Strava account to automatically import activities {formatSyncInterval(stravaConfig?.sync_interval_minutes)}. + Works for anything that ends up on Strava — Apple Watch, the Strava phone app, or another GPS watch. +

+ + {stReturn === 'connected' && ( +

✓ Strava connected — your first sync has started.

+ )} + {stReturn === 'error' && ( +

Strava connection failed or was cancelled. Please try again.

+ )} + {stReturn === 'scope' && ( +

Please tick “View data about your activities” when authorizing, so private activities can sync.

+ )} + + {!stravaConfig?.configured && ( +

+ Strava API credentials aren’t set on the server. An admin must register an app at + strava.com/settings/api and set STRAVA_CLIENT_ID / + STRAVA_CLIENT_SECRET (callback domain = this site’s domain). +

+ )} + + {stravaConfig?.connected ? ( + <> +
+ ✓ Connected{stravaConfig.athlete_name ? ` as ${stravaConfig.athlete_name}` : ''} +
+ {stravaConfig.last_sync_at && ( + + Last sync: {new Date(stravaConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} + + )} + {stravaConfig.last_sync_status && ( + + {stravaConfig.last_sync_status} + + )} +
+
+ + + + + setStForm(f => ({ ...f, sync_lookback_days: e.target.value }))} /> + + +
+ saveStrava.mutate({ + sync_enabled: stForm.sync_enabled, + sync_lookback_days: parseInt(stForm.sync_lookback_days, 10) || 30, + })} + loading={saveStrava.isPending} + saved={stSaved} + label="Update" + /> + + +
+ + ) : ( + + )} +
+ {/* PocketID — admin only */} {user?.is_admin && (
diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx index f387272..e17d6e5 100644 --- a/frontend/src/pages/UploadPage.jsx +++ b/frontend/src/pages/UploadPage.jsx @@ -169,20 +169,21 @@ export default function UploadPage() {
  • Click "Request Your Archive"
  • Download and upload the ZIP file below
  • +

    Handles the gzipped .fit.gz/.gpx.gz/.tcx.gz files Strava puts in the archive. For ongoing sync, connect Strava on the Profile page instead.

    - {/* Single FIT/GPX */} + {/* Single FIT/GPX/TCX */}