feat: gear registry — manage bikes/shoes per athlete, set per activity

- New /api/gear CRUD endpoints (gear.json per user)
- Gear tab in AthleteView (owner-only): add, edit, retire items
- EditDrawer gear field becomes a dropdown when registry has items
- Strava API sync now resolves gear_id → name, adds to registry automatically
- Strava ZIP import reads Gear column from activities.csv
- POST /api/strava/import-gear for one-time backfill from stored originals
This commit is contained in:
Davide Scaini
2026-05-24 12:33:41 +02:00
parent aca9f79b46
commit e553e08663
9 changed files with 576 additions and 10 deletions
+38
View File
@@ -100,18 +100,22 @@ def strava_sync_iter(
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
- ``"error"`` — fatal error before processing started; key: message
"""
import contextlib
import time
import uuid
from bincio.extract.strava_api import (
StravaError,
ensure_fresh,
fetch_activities,
fetch_gear,
fetch_streams,
save_token,
strava_meta_to_partial,
strava_to_parsed,
)
from bincio.extract.writer import make_activity_id
from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save
if not client_id or not client_secret:
yield {"type": "error", "message": "Strava not configured"}
@@ -137,6 +141,35 @@ def strava_sync_iter(
skipped = 0
errors: list[str] = []
# Cache: strava gear_id → gear name (avoid duplicate API calls within one sync)
_gear_name_cache: dict[str, str] = {}
def _resolve_gear(gear_id: str) -> str:
"""Return gear name for a Strava gear_id, adding to registry if new."""
if gear_id in _gear_name_cache:
return _gear_name_cache[gear_id]
# Check registry first
registry = _gear_load(data_dir)
existing = next((g for g in registry if g.get("strava_id") == gear_id), None)
if existing:
name = existing["name"]
_gear_name_cache[gear_id] = name
return name
# Fetch from Strava
details = fetch_gear(token["access_token"], gear_id)
name = details.get("name") or ""
if not name:
_gear_name_cache[gear_id] = ""
return ""
# Determine type from Strava: primary_type "A" = bike, "B" = shoe
gear_type = "shoes" if details.get("primary_type") == "B" else "bike"
# Add to registry
new_item: dict = {"id": str(uuid.uuid4()), "name": name, "type": gear_type, "retired": False, "strava_id": gear_id}
registry.append(new_item)
_gear_save(data_dir, registry)
_gear_name_cache[gear_id] = name
return name
for n, meta in enumerate(activities, 1):
name = meta.get("name", "Untitled")
try:
@@ -146,6 +179,11 @@ def strava_sync_iter(
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
continue
streams = fetch_streams(token["access_token"], meta["id"])
# Resolve gear name before converting
gear_id = meta.get("gear_id") or ""
if gear_id:
with contextlib.suppress(Exception):
meta["_gear_name"] = _resolve_gear(gear_id)
if originals_dir is not None:
orig_path = originals_dir / f"{activity_id}.json"
orig_path.write_text(