feat: Strava support — bulk-export .gz/.tcx import fix + .tcx parser; full Strava API OAuth live sync (activities via streams) with Profile connect UI; colour gym/no-GPS sports red
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s

This commit is contained in:
2026-06-21 10:18:09 +01:00
parent e8615dd12d
commit 07139120df
12 changed files with 1169 additions and 116 deletions
+271
View File
@@ -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"}
+41 -5
View File
@@ -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 <id>.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 {
+4
View File
@@ -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
+2 -1
View File
@@ -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"])
+22
View File
@@ -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"
+137
View File
@@ -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 {}
+290
View File
@@ -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
+137 -7
View File
@@ -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,10 +130,37 @@ 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:
return persist_activity(
db, user_id, parsed,
source_file=file_path,
garmin_activity_id=garmin_activity_id,
)
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"])
# Deduplicate: same user + sport_type + start_time within ±60s
from datetime import timedelta
# Fast-path dedup for re-syncs: same external id already imported.
if strava_activity_id:
existing = db.execute(
select(Activity).where(Activity.strava_activity_id == str(strava_activity_id))
).scalar_one_or_none()
if existing:
return {"activity_id": existing.id, "status": "duplicate"}
# 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,
@@ -136,15 +171,19 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
).scalars().first()
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.
# 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
from app.models.user import User as UserModel
user_obj = db.execute(
select(UserModel).where(UserModel.id == user_id)
).scalar_one_or_none()
@@ -166,6 +205,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
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"),
@@ -185,7 +225,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
training_stress_score=parsed.get("training_stress_score"),
polyline=parsed.get("polyline"),
bounding_box=parsed.get("bounding_box"),
source_file=file_path,
source_file=source_file,
source_type=parsed.get("source_type"),
hr_zones=hr_zones,
)
@@ -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."""
+7
View File
@@ -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
+7
View File
@@ -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
+147
View File
@@ -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() {
})()}
</Section>
{/* Strava Sync */}
<Section title="🟠 Strava Sync">
<p className="text-xs text-gray-500">
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.
</p>
{stReturn === 'connected' && (
<p className="text-xs text-green-400"> Strava connected your first sync has started.</p>
)}
{stReturn === 'error' && (
<p className="text-xs text-red-400">Strava connection failed or was cancelled. Please try again.</p>
)}
{stReturn === 'scope' && (
<p className="text-xs text-yellow-400">Please tick View data about your activities when authorizing, so private activities can sync.</p>
)}
{!stravaConfig?.configured && (
<p className="text-xs text-yellow-400">
Strava API credentials arent set on the server. An admin must register an app at
strava.com/settings/api and set <code className="text-gray-300">STRAVA_CLIENT_ID</code> /
<code className="text-gray-300"> STRAVA_CLIENT_SECRET</code> (callback domain = this sites domain).
</p>
)}
{stravaConfig?.connected ? (
<>
<div className="flex items-center justify-between bg-orange-900/20 border border-orange-800/40 rounded-lg px-3 py-2 text-xs flex-wrap gap-2">
<span className="text-orange-300"> Connected{stravaConfig.athlete_name ? ` as ${stravaConfig.athlete_name}` : ''}</span>
<div className="flex items-center gap-3 flex-wrap">
{stravaConfig.last_sync_at && (
<span className="text-gray-500">
Last sync: {new Date(stravaConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
)}
{stravaConfig.last_sync_status && (
<span className={stravaConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : /error/i.test(stravaConfig.last_sync_status) ? 'text-red-400' : 'text-yellow-400'}>
{stravaConfig.last_sync_status}
</span>
)}
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer pt-1">
<input type="checkbox" checked={stForm.sync_enabled}
onChange={e => setStForm(f => ({ ...f, sync_enabled: e.target.checked }))}
className="w-4 h-4 accent-orange-500" />
<span className="text-sm text-gray-300">Enable automatic sync ({formatSyncInterval(stravaConfig?.sync_interval_minutes)})</span>
</label>
<Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history). After that, scheduled syncs only refresh the last day or two. Large backfills may hit Strava's rate limits and resume on the next sync.">
<Input type="number" value={stForm.sync_lookback_days} min={-1}
onChange={e => setStForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
</Field>
<div className="flex items-center gap-3 flex-wrap pt-1">
<SaveButton
onClick={() => saveStrava.mutate({
sync_enabled: stForm.sync_enabled,
sync_lookback_days: parseInt(stForm.sync_lookback_days, 10) || 30,
})}
loading={saveStrava.isPending}
saved={stSaved}
label="Update"
/>
<button
onClick={() => triggerStrava.mutate()}
disabled={stravaSyncing || triggerStrava.isPending}
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">
{stravaSyncing ? 'Syncing…' : '↻ Sync now'}
</button>
<button
onClick={() => { if (confirm('Disconnect Strava?')) deleteStrava.mutate() }}
className="text-red-400 hover:text-red-300 text-sm transition-colors">
Disconnect
</button>
</div>
</>
) : (
<button
onClick={() => connectStrava.mutate()}
disabled={!stravaConfig?.configured || connectStrava.isPending}
className="inline-flex items-center gap-2 bg-[#FC4C02] hover:bg-[#e34402] disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{connectStrava.isPending ? 'Redirecting…' : 'Connect with Strava'}
</button>
)}
</Section>
{/* PocketID — admin only */}
{user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)">
+4 -3
View File
@@ -169,20 +169,21 @@ export default function UploadPage() {
<li>Click "Request Your Archive"</li>
<li>Download and upload the ZIP file below</li>
</ol>
<p className="text-gray-500 text-xs mt-2">Handles the gzipped <code>.fit.gz</code>/<code>.gpx.gz</code>/<code>.tcx.gz</code> files Strava puts in the archive. For ongoing sync, connect Strava on the Profile page instead.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Single FIT/GPX */}
{/* Single FIT/GPX/TCX */}
<UploadZone
title="Single activity"
description="Upload a .fit or .gpx file"
description="Upload a .fit, .gpx or .tcx file"
icon="🏃"
endpoint="/upload/activity"
accept={{
'application/octet-stream': ['.fit'],
'application/gpx+xml': ['.gpx'],
'text/xml': ['.gpx'],
'text/xml': ['.gpx', '.tcx'],
}}
/>