feat: part lifespan tracking in gear tab
API (gear.py):
- POST /api/gear/{id}/parts
- PATCH /api/gear/{id}/parts/{pid}
- DELETE /api/gear/{id}/parts/{pid}
- POST /api/gear/{id}/parts/{pid}/replacements
- DELETE /api/gear/{id}/parts/{pid}/replacements/{rid}
UI (AthleteView.svelte):
- Gear rows are now accordion-expandable
- Collapsed row shows colored status dots (green/yellow/red) per part
- Expanded section: parts list with km-since-replacement colored by threshold,
Replaced button with date+note form, recent log entries, add-part form
- Contextual suggestion for first part (chain for bikes, shoes for running)
- Edit/delete gear moved into expanded section
This commit is contained in:
@@ -132,3 +132,161 @@ async def gear_delete(
|
||||
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Parts ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_item(items: list[dict], item_id: str) -> tuple[int, dict]:
|
||||
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")
|
||||
return idx, items[idx]
|
||||
|
||||
|
||||
@router.post("/api/gear/{item_id}/parts")
|
||||
async def part_add(
|
||||
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, item = _find_item(items, item_id)
|
||||
|
||||
body = await request.json()
|
||||
name = str(body.get("name", "")).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
threshold_km = body.get("threshold_km")
|
||||
if threshold_km is not None:
|
||||
try:
|
||||
threshold_km = float(threshold_km)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "threshold_km must be a number")
|
||||
|
||||
part: dict = {"id": str(uuid.uuid4()), "name": name, "replacements": []}
|
||||
if threshold_km is not None:
|
||||
part["threshold_km"] = threshold_km
|
||||
|
||||
item = dict(item)
|
||||
item.setdefault("parts", [])
|
||||
item["parts"] = [*item["parts"], part]
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "part": part}, status_code=201)
|
||||
|
||||
|
||||
@router.patch("/api/gear/{item_id}/parts/{part_id}")
|
||||
async def part_update(
|
||||
item_id: str,
|
||||
part_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, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
body = await request.json()
|
||||
part = dict(parts[pidx])
|
||||
if "name" in body:
|
||||
name = str(body["name"]).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name cannot be empty")
|
||||
part["name"] = name
|
||||
if "threshold_km" in body:
|
||||
try:
|
||||
part["threshold_km"] = float(body["threshold_km"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "threshold_km must be a number")
|
||||
|
||||
parts[pidx] = part
|
||||
item = {**item, "parts": parts}
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "part": part})
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}/parts/{part_id}")
|
||||
async def part_delete(
|
||||
item_id: str,
|
||||
part_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)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = [p for p in item.get("parts", []) if p["id"] != part_id]
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/gear/{item_id}/parts/{part_id}/replacements")
|
||||
async def replacement_add(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Log a replacement event for a part. date defaults to today (UTC)."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
body = await request.json()
|
||||
date = str(body.get("date", "")).strip() or datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
note = str(body.get("note", "")).strip() or None
|
||||
|
||||
entry: dict = {"id": str(uuid.uuid4()), "date": date}
|
||||
if note:
|
||||
entry["note"] = note
|
||||
|
||||
part = dict(parts[pidx])
|
||||
part["replacements"] = [*part.get("replacements", []), entry]
|
||||
parts[pidx] = part
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "replacement": entry}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}/parts/{part_id}/replacements/{replacement_id}")
|
||||
async def replacement_delete(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
replacement_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)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
part = dict(parts[pidx])
|
||||
part["replacements"] = [r for r in part.get("replacements", []) if r["id"] != replacement_id]
|
||||
parts[pidx] = part
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
Reference in New Issue
Block a user