From e553e0866307ea1a11e2d4cf11ee0c5ccd974088 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sun, 24 May 2026 12:33:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20gear=20registry=20=E2=80=94=20manage=20?= =?UTF-8?q?bikes/shoes=20per=20athlete,=20set=20per=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bincio/extract/ingest.py | 38 ++++++ bincio/extract/strava_api.py | 10 ++ bincio/extract/strava_zip.py | 2 + bincio/serve/routers/gear.py | 134 +++++++++++++++++++ bincio/serve/routers/strava.py | 99 ++++++++++++++ bincio/serve/server.py | 2 + gear_plan.md | 64 +++++++++ site/src/components/AthleteView.svelte | 177 ++++++++++++++++++++++++- site/src/components/EditDrawer.svelte | 60 +++++++-- 9 files changed, 576 insertions(+), 10 deletions(-) create mode 100644 bincio/serve/routers/gear.py create mode 100644 gear_plan.md diff --git a/bincio/extract/ingest.py b/bincio/extract/ingest.py index 986e4c9..722de02 100644 --- a/bincio/extract/ingest.py +++ b/bincio/extract/ingest.py @@ -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( diff --git a/bincio/extract/strava_api.py b/bincio/extract/strava_api.py index 4675ae9..e46d6d7 100644 --- a/bincio/extract/strava_api.py +++ b/bincio/extract/strava_api.py @@ -150,6 +150,15 @@ def fetch_streams(access_token: str, activity_id: int) -> dict: return result if isinstance(result, dict) else {} +def fetch_gear(access_token: str, gear_id: str) -> dict: + """Fetch gear details for a single gear item. Returns {} on error.""" + try: + result = _api_get(f"{_API_BASE}/gear/{gear_id}", access_token) + return result if isinstance(result, dict) else {} + except StravaError: + return {} + + # ── Model conversion ─────────────────────────────────────────────────────────── def strava_meta_to_partial(meta: dict) -> ParsedActivity: @@ -215,4 +224,5 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity: description=meta.get("description") or None, strava_id=str(meta["id"]), privacy="unlisted" if is_private else "public", + gear=meta.get("_gear_name") or None, ) diff --git a/bincio/extract/strava_zip.py b/bincio/extract/strava_zip.py index 8eda4d2..3081a66 100644 --- a/bincio/extract/strava_zip.py +++ b/bincio/extract/strava_zip.py @@ -115,6 +115,8 @@ def strava_zip_iter( parsed.description = meta_row["Activity Description"].strip() if not parsed.strava_id and meta_row.get("Activity ID"): parsed.strava_id = meta_row["Activity ID"].strip() + if not parsed.gear and meta_row.get("Gear"): + parsed.gear = meta_row["Gear"].strip() if originals_dir is not None: import shutil diff --git a/bincio/serve/routers/gear.py b/bincio/serve/routers/gear.py new file mode 100644 index 0000000..90498d9 --- /dev/null +++ b/bincio/serve/routers/gear.py @@ -0,0 +1,134 @@ +"""Gear registry endpoints (/api/gear).""" +from __future__ import annotations + +import json +import uuid +from pathlib import Path + +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse + +from bincio.serve import deps + +router = APIRouter() + +_GEAR_TYPES = {"bike", "shoes", "skis", "other"} + + +def _gear_path(user_dir: Path) -> Path: + return user_dir / "gear.json" + + +def _load(user_dir: Path) -> list[dict]: + p = _gear_path(user_dir) + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + return data.get("items", []) + except (OSError, json.JSONDecodeError): + return [] + + +def _save(user_dir: Path, items: list[dict]) -> None: + _gear_path(user_dir).write_text( + json.dumps({"items": items}, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +@router.get("/api/gear") +async def gear_list(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: + user = deps._require_user(bincio_session) + items = _load(deps._get_data_dir() / user.handle) + return JSONResponse({"items": items}) + + +@router.post("/api/gear") +async def gear_add( + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + body = await request.json() + name = str(body.get("name", "")).strip() + if not name: + raise HTTPException(400, "name is required") + gear_type = str(body.get("type", "other")).strip() + if gear_type not in _GEAR_TYPES: + raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}") + strava_id = str(body.get("strava_id", "")).strip() or None + + user_dir = deps._get_data_dir() / user.handle + items = _load(user_dir) + + # Deduplicate by strava_id if provided + if strava_id and any(i.get("strava_id") == strava_id for i in items): + existing = next(i for i in items if i.get("strava_id") == strava_id) + return JSONResponse({"ok": True, "item": existing, "created": False}) + + item: dict = { + "id": str(uuid.uuid4()), + "name": name, + "type": gear_type, + "retired": False, + } + if strava_id: + item["strava_id"] = strava_id + + items.append(item) + _save(user_dir, items) + return JSONResponse({"ok": True, "item": item, "created": True}, status_code=201) + + +@router.patch("/api/gear/{item_id}") +async def gear_update( + item_id: str, + request: Request, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + user_dir = deps._get_data_dir() / user.handle + items = _load(user_dir) + + idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None) + if idx is None: + raise HTTPException(404, "Gear item not found") + + body = await request.json() + item = dict(items[idx]) + + if "name" in body: + name = str(body["name"]).strip() + if not name: + raise HTTPException(400, "name cannot be empty") + item["name"] = name + if "type" in body: + gear_type = str(body["type"]).strip() + if gear_type not in _GEAR_TYPES: + raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}") + item["type"] = gear_type + if "retired" in body: + item["retired"] = bool(body["retired"]) + + items[idx] = item + _save(user_dir, items) + return JSONResponse({"ok": True, "item": item}) + + +@router.delete("/api/gear/{item_id}") +async def gear_delete( + item_id: str, + bincio_session: str | None = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + user_dir = deps._get_data_dir() / user.handle + items = _load(user_dir) + + before = len(items) + items = [g for g in items if g["id"] != item_id] + if len(items) == before: + raise HTTPException(404, "Gear item not found") + + _save(user_dir, items) + return JSONResponse({"ok": True}) diff --git a/bincio/serve/routers/strava.py b/bincio/serve/routers/strava.py index fd46047..7b343e4 100644 --- a/bincio/serve/routers/strava.py +++ b/bincio/serve/routers/strava.py @@ -171,6 +171,105 @@ async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(defaul ) +@router.post("/api/strava/import-gear") +async def serve_strava_import_gear(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """One-time backfill: scan stored Strava originals for gear_ids, fetch names, populate gear registry.""" + user = deps._require_user(bincio_session) + cid, csec = deps._strava_creds(user.handle) + if not cid or not csec: + raise HTTPException(400, "Strava not configured on this server") + dd = deps._get_data_dir() / user.handle + originals_dir = dd / "originals" / "strava" + if not originals_dir.exists(): + return JSONResponse({"ok": True, "gear_added": 0, "activities_updated": 0, "message": "No stored originals found"}) + + import contextlib + import uuid + + from bincio.extract.strava_api import StravaError, ensure_fresh, fetch_gear + from bincio.render.merge import merge_one + from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save + + try: + token = ensure_fresh(dd, cid, csec) + except StravaError as e: + raise HTTPException(502, str(e)) + + registry = _gear_load(dd) + known_strava_ids = {g.get("strava_id") for g in registry if g.get("strava_id")} + + # Collect all unique gear_ids from originals + gear_id_to_activities: dict[str, list[str]] = {} + for orig_path in originals_dir.glob("*.json"): + try: + data = json.loads(orig_path.read_text(encoding="utf-8")) + gear_id = (data.get("meta") or {}).get("gear_id") or "" + if gear_id: + gear_id_to_activities.setdefault(gear_id, []).append(orig_path.stem) + except (OSError, json.JSONDecodeError): + continue + + gear_added = 0 + activities_updated = 0 + + for gear_id, activity_ids in gear_id_to_activities.items(): + if gear_id in known_strava_ids: + gear_name = next(g["name"] for g in registry if g.get("strava_id") == gear_id) + else: + details = fetch_gear(token["access_token"], gear_id) + gear_name = details.get("name") or "" + if not gear_name: + continue + gear_type = "shoes" if details.get("primary_type") == "B" else "bike" + new_item: dict = {"id": str(uuid.uuid4()), "name": gear_name, "type": gear_type, "retired": False, "strava_id": gear_id} + registry.append(new_item) + known_strava_ids.add(gear_id) + gear_added += 1 + + # Backfill: write sidecar for each activity that has no gear set yet + import yaml as _yaml + edits_dir = dd / "edits" + edits_dir.mkdir(exist_ok=True) + for activity_id in activity_ids: + activity_json = dd / "activities" / f"{activity_id}.json" + if not activity_json.exists(): + continue + try: + act = json.loads(activity_json.read_text(encoding="utf-8")) + if act.get("gear"): + continue # already has gear + except (OSError, json.JSONDecodeError): + continue + sidecar = edits_dir / f"{activity_id}.md" + fm: dict = {} + body = "" + if sidecar.exists(): + try: + text = sidecar.read_text(encoding="utf-8") + import re as _re + parts = _re.split(r"^---[ \t]*$", text, maxsplit=2, flags=_re.MULTILINE) + if len(parts) >= 3: + fm = _yaml.safe_load(parts[1]) or {} + body = parts[2].strip() + except Exception: + pass + if fm.get("gear"): + continue # sidecar already sets gear + fm["gear"] = gear_name + fm_text = _yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip() + content = f"---\n{fm_text}\n---\n" + if body: + content += f"\n{body}\n" + sidecar.write_text(content, encoding="utf-8") + with contextlib.suppress(Exception): + merge_one(dd, activity_id) + activities_updated += 1 + + _gear_save(dd, registry) + tasks._trigger_rebuild(user.handle) + return JSONResponse({"ok": True, "gear_added": gear_added, "activities_updated": activities_updated}) + + @router.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = deps._require_user(bincio_session) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index a296a9a..225289f 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -23,6 +23,7 @@ from bincio.serve.routers import ( download, feed, garmin, + gear, ideas, me, ogimage, @@ -69,5 +70,6 @@ for _router in [ garmin.router, ideas.router, ogimage.router, + gear.router, ]: app.include_router(_router) diff --git a/gear_plan.md b/gear_plan.md new file mode 100644 index 0000000..453a97b --- /dev/null +++ b/gear_plan.md @@ -0,0 +1,64 @@ +# Gear Feature Plan + +## Why the gap exists + +Neither sync path populates gear today. The Strava API returns `gear_id` per activity +(brut's originals show `b3437566`, `g10422777` etc.) but `strava_to_parsed()` ignores it. +The ZIP path also ignores the gear column in activities.csv. +Diego_p's "Rose Backroad" was set manually via the EditDrawer free-text field. + +--- + +## Data model — `{user_dir}/gear.json` + +```json +{ + "items": [ + { + "id": "uuid-abc123", + "name": "Rose Backroad", + "type": "bike", + "retired": false, + "strava_id": "b3437566" + } + ] +} +``` + +- `type` enum: `bike | shoes | skis | other` +- Per-activity gear stays as a plain string (the gear **name**) — backward compatible with existing sidecars +- `strava_id` is optional, used for deduplication during Strava sync + +--- + +## Build order + +### [x] Step 1 — `gear.json` CRUD API ✓ +File: `bincio/serve/routers/gear.py` +- `GET /api/gear` → list items (auth required) +- `POST /api/gear` → add item (auto-generate UUID id) +- `PATCH /api/gear/{id}` → update (name, type, retired) +- `DELETE /api/gear/{id}` → delete +File lives at `{user_dir}/gear.json`, same pattern as `athlete.json`. +Add gear router to `server.py`. + +### [x] Step 2 — Gear tab in AthleteView (ownerOnly) ✓ +- Added `'gear'` to `Tab` type and `ALL_TABS` in `AthleteView.svelte` +- Inline gear management: list, add, edit, retire — no separate component + +### [x] Step 3 — EditDrawer gear selector ✓ +- At drawer open, fetches `/api/gear` +- Shows ` + + + + + + {:else} +
+ {GEAR_ICONS[item.type] ?? '⚙️'} + {item.name} + {#if item.retired} + Retired + {/if} + + +
+ {/if} + {/each} + + {:else} +

No gear registered yet.

+ {/if} + + +
+ e.key === 'Enter' && gearAdd()} + /> + + +
+ {/if} + + {:else if activeTab === 'profile'}
diff --git a/site/src/components/EditDrawer.svelte b/site/src/components/EditDrawer.svelte index 9571e94..8a5372a 100644 --- a/site/src/components/EditDrawer.svelte +++ b/site/src/components/EditDrawer.svelte @@ -48,6 +48,13 @@ let hideStats: string[] = []; let images: string[] = []; + // Gear registry + interface GearItem { id: string; name: string; type: string; retired: boolean } + let gearItems: GearItem[] = []; + let gearSelectValue = ''; // the + {#if gearItems.length > 0} + + {#if gearIsOther} + + {/if} + {:else} + + {/if}
{#if SUB_SPORTS[sport]}