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:
@@ -100,18 +100,22 @@ def strava_sync_iter(
|
|||||||
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
|
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
|
||||||
- ``"error"`` — fatal error before processing started; key: message
|
- ``"error"`` — fatal error before processing started; key: message
|
||||||
"""
|
"""
|
||||||
|
import contextlib
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from bincio.extract.strava_api import (
|
from bincio.extract.strava_api import (
|
||||||
StravaError,
|
StravaError,
|
||||||
ensure_fresh,
|
ensure_fresh,
|
||||||
fetch_activities,
|
fetch_activities,
|
||||||
|
fetch_gear,
|
||||||
fetch_streams,
|
fetch_streams,
|
||||||
save_token,
|
save_token,
|
||||||
strava_meta_to_partial,
|
strava_meta_to_partial,
|
||||||
strava_to_parsed,
|
strava_to_parsed,
|
||||||
)
|
)
|
||||||
from bincio.extract.writer import make_activity_id
|
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:
|
if not client_id or not client_secret:
|
||||||
yield {"type": "error", "message": "Strava not configured"}
|
yield {"type": "error", "message": "Strava not configured"}
|
||||||
@@ -137,6 +141,35 @@ def strava_sync_iter(
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
errors: list[str] = []
|
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):
|
for n, meta in enumerate(activities, 1):
|
||||||
name = meta.get("name", "Untitled")
|
name = meta.get("name", "Untitled")
|
||||||
try:
|
try:
|
||||||
@@ -146,6 +179,11 @@ def strava_sync_iter(
|
|||||||
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
|
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
|
||||||
continue
|
continue
|
||||||
streams = fetch_streams(token["access_token"], meta["id"])
|
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:
|
if originals_dir is not None:
|
||||||
orig_path = originals_dir / f"{activity_id}.json"
|
orig_path = originals_dir / f"{activity_id}.json"
|
||||||
orig_path.write_text(
|
orig_path.write_text(
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ def fetch_streams(access_token: str, activity_id: int) -> dict:
|
|||||||
return result if isinstance(result, dict) else {}
|
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 ───────────────────────────────────────────────────────────
|
# ── Model conversion ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def strava_meta_to_partial(meta: dict) -> ParsedActivity:
|
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,
|
description=meta.get("description") or None,
|
||||||
strava_id=str(meta["id"]),
|
strava_id=str(meta["id"]),
|
||||||
privacy="unlisted" if is_private else "public",
|
privacy="unlisted" if is_private else "public",
|
||||||
|
gear=meta.get("_gear_name") or None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ def strava_zip_iter(
|
|||||||
parsed.description = meta_row["Activity Description"].strip()
|
parsed.description = meta_row["Activity Description"].strip()
|
||||||
if not parsed.strava_id and meta_row.get("Activity ID"):
|
if not parsed.strava_id and meta_row.get("Activity ID"):
|
||||||
parsed.strava_id = meta_row["Activity ID"].strip()
|
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:
|
if originals_dir is not None:
|
||||||
import shutil
|
import shutil
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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")
|
@router.post("/api/strava/sync")
|
||||||
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||||
user = deps._require_user(bincio_session)
|
user = deps._require_user(bincio_session)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from bincio.serve.routers import (
|
|||||||
download,
|
download,
|
||||||
feed,
|
feed,
|
||||||
garmin,
|
garmin,
|
||||||
|
gear,
|
||||||
ideas,
|
ideas,
|
||||||
me,
|
me,
|
||||||
ogimage,
|
ogimage,
|
||||||
@@ -69,5 +70,6 @@ for _router in [
|
|||||||
garmin.router,
|
garmin.router,
|
||||||
ideas.router,
|
ideas.router,
|
||||||
ogimage.router,
|
ogimage.router,
|
||||||
|
gear.router,
|
||||||
]:
|
]:
|
||||||
app.include_router(_router)
|
app.include_router(_router)
|
||||||
|
|||||||
@@ -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 `<select>` from registry (if items exist), with "Other…" revealing text input
|
||||||
|
- Falls back to plain text input if no gear items registered
|
||||||
|
- Value still stored as gear name string — backward compatible
|
||||||
|
|
||||||
|
### [x] Step 4 — Strava sync gear extraction ✓
|
||||||
|
- `strava_api.py`: added `fetch_gear()` + `gear` field on `strava_to_parsed()` via `_gear_name` meta key
|
||||||
|
- `ingest.py`: during sync, resolves gear_id → name, adds new items to registry
|
||||||
|
- New endpoint `POST /api/strava/import-gear`: one-time backfill from stored originals
|
||||||
|
|
||||||
|
### [x] Step 5 — ZIP import gear column ✓
|
||||||
|
- `strava_zip.py`: reads `Gear` column from activities.csv and sets `parsed.gear`
|
||||||
|
|
||||||
|
### [x] Step 6 — One-time backfill endpoint ✓
|
||||||
|
`POST /api/strava/import-gear` implemented in `strava.py`.
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
let drawerOpen = false;
|
let drawerOpen = false;
|
||||||
|
|
||||||
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
|
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd' | 'gear';
|
||||||
let activeTab: Tab = 'power';
|
let activeTab: Tab = 'power';
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
let isOwner = false;
|
let isOwner = false;
|
||||||
@@ -50,6 +50,82 @@
|
|||||||
let effortsBySegment: Record<string, SegmentEffort[]> = {};
|
let effortsBySegment: Record<string, SegmentEffort[]> = {};
|
||||||
let loadingEfforts: Record<string, boolean> = {};
|
let loadingEfforts: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
// Gear tab state
|
||||||
|
interface GearItem { id: string; name: string; type: string; retired: boolean; strava_id?: string }
|
||||||
|
let gearItems: GearItem[] = [];
|
||||||
|
let gearLoading = false;
|
||||||
|
let gearFetched = false;
|
||||||
|
let gearAddName = '';
|
||||||
|
let gearAddType = 'bike';
|
||||||
|
let gearAdding = false;
|
||||||
|
let gearError: string | null = null;
|
||||||
|
let gearEditId: string | null = null;
|
||||||
|
let gearEditName = '';
|
||||||
|
let gearEditType = 'bike';
|
||||||
|
let gearEditRetired = false;
|
||||||
|
let gearSaving = false;
|
||||||
|
|
||||||
|
const GEAR_ICONS: Record<string, string> = { bike: '🚲', shoes: '👟', skis: '🎿', other: '⚙️' };
|
||||||
|
|
||||||
|
async function gearFetch() {
|
||||||
|
if (gearFetched) return;
|
||||||
|
gearLoading = true; gearFetched = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/gear', { credentials: 'include' });
|
||||||
|
if (r.ok) gearItems = (await r.json()).items ?? [];
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
gearLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gearAdd() {
|
||||||
|
if (!gearAddName.trim()) return;
|
||||||
|
gearAdding = true; gearError = null;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/gear', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: gearAddName.trim(), type: gearAddType }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) { gearItems = [...gearItems, d.item]; gearAddName = ''; }
|
||||||
|
else gearError = d.detail ?? 'Could not add gear.';
|
||||||
|
} catch { gearError = 'Could not reach server.'; }
|
||||||
|
gearAdding = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gearStartEdit(item: GearItem) {
|
||||||
|
gearEditId = item.id; gearEditName = item.name; gearEditType = item.type; gearEditRetired = item.retired;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gearSaveEdit() {
|
||||||
|
if (!gearEditId || !gearEditName.trim()) return;
|
||||||
|
gearSaving = true; gearError = null;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/gear/${gearEditId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: gearEditName.trim(), type: gearEditType, retired: gearEditRetired }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
gearItems = gearItems.map(g => g.id === gearEditId ? d.item : g);
|
||||||
|
gearEditId = null;
|
||||||
|
} else gearError = d.detail ?? 'Could not save.';
|
||||||
|
} catch { gearError = 'Could not reach server.'; }
|
||||||
|
gearSaving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gearDelete(id: string) {
|
||||||
|
gearError = null;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/gear/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
if (r.ok) gearItems = gearItems.filter(g => g.id !== id);
|
||||||
|
else { const d = await r.json(); gearError = d.detail ?? 'Could not delete.'; }
|
||||||
|
} catch { gearError = 'Could not reach server.'; }
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleSegment(id: string) {
|
async function toggleSegment(id: string) {
|
||||||
if (expandedId === id) { expandedId = null; return; }
|
if (expandedId === id) { expandedId = null; return; }
|
||||||
expandedId = id;
|
expandedId = id;
|
||||||
@@ -73,6 +149,8 @@
|
|||||||
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: if (activeTab === 'gear' && isOwner && !gearFetched && !gearLoading) gearFetch();
|
||||||
|
|
||||||
$: if (activeTab === 'segments' && segmentsHandle && !segmentsFetched && !segmentsLoading) {
|
$: if (activeTab === 'segments' && segmentsHandle && !segmentsFetched && !segmentsLoading) {
|
||||||
segmentsLoading = true;
|
segmentsLoading = true;
|
||||||
segmentsFetched = true;
|
segmentsFetched = true;
|
||||||
@@ -94,7 +172,7 @@
|
|||||||
isOwner = (e as CustomEvent<string>).detail === handle;
|
isOwner = (e as CustomEvent<string>).detail === handle;
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
}
|
||||||
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd'];
|
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd', 'gear'];
|
||||||
const rawTab = new URLSearchParams(window.location.search).get('tab');
|
const rawTab = new URLSearchParams(window.location.search).get('tab');
|
||||||
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
||||||
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
|
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
|
||||||
@@ -163,6 +241,7 @@
|
|||||||
{ key: 'profile', label: 'Profile' },
|
{ key: 'profile', label: 'Profile' },
|
||||||
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
||||||
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
|
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
|
||||||
|
{ key: 'gear', label: 'Gear', ownerOnly: true },
|
||||||
];
|
];
|
||||||
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
||||||
</script>
|
</script>
|
||||||
@@ -357,6 +436,100 @@
|
|||||||
<Explore {handle} {base} embedded={true} />
|
<Explore {handle} {base} embedded={true} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gear tab -->
|
||||||
|
{:else if activeTab === 'gear'}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#if gearError}
|
||||||
|
<p class="text-red-400 text-sm">{gearError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if gearLoading}
|
||||||
|
<p class="text-zinc-400 text-sm">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
{#if gearItems.length > 0}
|
||||||
|
<div class="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||||
|
{#each gearItems as item (item.id)}
|
||||||
|
{#if gearEditId === item.id}
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<div class="px-4 py-3 border-b border-zinc-800 flex flex-wrap gap-2 items-center bg-zinc-800/50">
|
||||||
|
<input
|
||||||
|
bind:value={gearEditName}
|
||||||
|
class="flex-1 min-w-32 bg-zinc-700 text-white text-sm px-2 py-1 rounded border border-zinc-600 focus:border-blue-500 outline-none"
|
||||||
|
placeholder="Name"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={gearEditType}
|
||||||
|
class="bg-zinc-700 text-white text-sm px-2 py-1 rounded border border-zinc-600 focus:border-blue-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="bike">Bike</option>
|
||||||
|
<option value="shoes">Shoes</option>
|
||||||
|
<option value="skis">Skis</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<label class="flex items-center gap-1.5 text-sm text-zinc-400 cursor-pointer">
|
||||||
|
<input type="checkbox" bind:checked={gearEditRetired} class="accent-blue-500" />
|
||||||
|
Retired
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
on:click={gearSaveEdit}
|
||||||
|
disabled={gearSaving || !gearEditName.trim()}
|
||||||
|
class="text-xs px-3 py-1 rounded bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-40"
|
||||||
|
>{gearSaving ? 'Saving…' : 'Save'}</button>
|
||||||
|
<button
|
||||||
|
on:click={() => gearEditId = null}
|
||||||
|
class="text-xs px-3 py-1 rounded border border-zinc-600 text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="px-4 py-3 border-b border-zinc-800 flex items-center gap-3 last:border-0">
|
||||||
|
<span class="text-lg leading-none">{GEAR_ICONS[item.type] ?? '⚙️'}</span>
|
||||||
|
<span class="flex-1 text-sm text-white" class:text-zinc-500={item.retired}>{item.name}</span>
|
||||||
|
{#if item.retired}
|
||||||
|
<span class="text-xs text-zinc-600 border border-zinc-700 rounded px-1.5 py-0.5">Retired</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
on:click={() => gearStartEdit(item)}
|
||||||
|
class="text-xs text-zinc-500 hover:text-white transition-colors"
|
||||||
|
aria-label="Edit {item.name}"
|
||||||
|
>Edit</button>
|
||||||
|
<button
|
||||||
|
on:click={() => gearDelete(item.id)}
|
||||||
|
class="text-xs text-zinc-600 hover:text-red-400 transition-colors"
|
||||||
|
aria-label="Delete {item.name}"
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-zinc-500 text-sm">No gear registered yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add gear form -->
|
||||||
|
<div class="flex flex-wrap gap-2 items-center pt-1">
|
||||||
|
<input
|
||||||
|
bind:value={gearAddName}
|
||||||
|
placeholder="Name (e.g. Rose Backroad)"
|
||||||
|
class="flex-1 min-w-40 bg-zinc-800 text-white text-sm px-3 py-1.5 rounded-lg border border-zinc-700 focus:border-blue-500 outline-none placeholder:text-zinc-600"
|
||||||
|
on:keydown={e => e.key === 'Enter' && gearAdd()}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={gearAddType}
|
||||||
|
class="bg-zinc-800 text-white text-sm px-2 py-1.5 rounded-lg border border-zinc-700 focus:border-blue-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="bike">Bike</option>
|
||||||
|
<option value="shoes">Shoes</option>
|
||||||
|
<option value="skis">Skis</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
on:click={gearAdd}
|
||||||
|
disabled={gearAdding || !gearAddName.trim()}
|
||||||
|
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white transition-colors disabled:opacity-40"
|
||||||
|
>{gearAdding ? 'Adding…' : '+ Add gear'}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Profile tab -->
|
<!-- Profile tab -->
|
||||||
{:else if activeTab === 'profile'}
|
{:else if activeTab === 'profile'}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -48,6 +48,13 @@
|
|||||||
let hideStats: string[] = [];
|
let hideStats: string[] = [];
|
||||||
let images: string[] = [];
|
let images: string[] = [];
|
||||||
|
|
||||||
|
// Gear registry
|
||||||
|
interface GearItem { id: string; name: string; type: string; retired: boolean }
|
||||||
|
let gearItems: GearItem[] = [];
|
||||||
|
let gearSelectValue = ''; // the <select> value; '__other__' means free-text mode
|
||||||
|
$: gearIsOther = gearSelectValue === '__other__';
|
||||||
|
$: if (!gearIsOther) gear = gearSelectValue; // sync gear when using dropdown
|
||||||
|
|
||||||
// Image upload
|
// Image upload
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
@@ -59,7 +66,10 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
loadError = '';
|
loadError = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(api);
|
const [res, gearRes] = await Promise.all([
|
||||||
|
fetch(api),
|
||||||
|
fetch('/api/gear', { credentials: 'include' }).catch(() => null),
|
||||||
|
]);
|
||||||
if (!res.ok) throw new Error(`Edit server returned ${res.status} — is bincio edit running?`);
|
if (!res.ok) throw new Error(`Edit server returned ${res.status} — is bincio edit running?`);
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
title = d.title ?? '';
|
title = d.title ?? '';
|
||||||
@@ -75,6 +85,18 @@
|
|||||||
downloadDisabled = d.download_disabled ?? false;
|
downloadDisabled = d.download_disabled ?? false;
|
||||||
hideStats = d.hide_stats ?? [];
|
hideStats = d.hide_stats ?? [];
|
||||||
images = d.images ?? [];
|
images = d.images ?? [];
|
||||||
|
|
||||||
|
if (gearRes?.ok) {
|
||||||
|
gearItems = ((await gearRes.json()).items ?? []).filter((g: GearItem) => !g.retired);
|
||||||
|
}
|
||||||
|
// Set dropdown to current gear value, or 'Other' if it's a custom string not in the list
|
||||||
|
if (!gear) {
|
||||||
|
gearSelectValue = '';
|
||||||
|
} else if (gearItems.some(g => g.name === gear)) {
|
||||||
|
gearSelectValue = gear;
|
||||||
|
} else {
|
||||||
|
gearSelectValue = '__other__';
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
loadError = e.message;
|
loadError = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -233,6 +255,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-zinc-500 mb-1" for="ed-gear">Gear</label>
|
<label class="block text-xs text-zinc-500 mb-1" for="ed-gear">Gear</label>
|
||||||
|
{#if gearItems.length > 0}
|
||||||
|
<select
|
||||||
|
id="ed-gear"
|
||||||
|
bind:value={gearSelectValue}
|
||||||
|
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{#each gearItems as g (g.id)}
|
||||||
|
<option value={g.name}>{g.name}</option>
|
||||||
|
{/each}
|
||||||
|
<option value="__other__">Other…</option>
|
||||||
|
</select>
|
||||||
|
{#if gearIsOther}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={gear}
|
||||||
|
placeholder="Type gear name"
|
||||||
|
class="mt-1.5 w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
<input
|
<input
|
||||||
id="ed-gear"
|
id="ed-gear"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -240,6 +283,7 @@
|
|||||||
placeholder="e.g. Trek Domane"
|
placeholder="e.g. Trek Domane"
|
||||||
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors"
|
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if SUB_SPORTS[sport]}
|
{#if SUB_SPORTS[sport]}
|
||||||
|
|||||||
Reference in New Issue
Block a user