diff --git a/bincio/serve/routers/gear.py b/bincio/serve/routers/gear.py index 90498d9..339656d 100644 --- a/bincio/serve/routers/gear.py +++ b/bincio/serve/routers/gear.py @@ -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}) diff --git a/site/src/components/ActivityDetailLoader.svelte b/site/src/components/ActivityDetailLoader.svelte index 857864e..affa83e 100644 --- a/site/src/components/ActivityDetailLoader.svelte +++ b/site/src/components/ActivityDetailLoader.svelte @@ -45,6 +45,7 @@ avg_power_w: d.avg_power_w ?? null, mmp: d.mmp ?? null, source: d.source ?? null, + gear: d.gear ?? null, privacy: d.privacy ?? 'public', detail_url: detailUrl, track_url: d.bbox && d.privacy !== 'no_gps' diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 538b8b5..d23fb23 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -51,7 +51,10 @@ let loadingEfforts: Record = {}; // Gear tab state - interface GearItem { id: string; name: string; type: string; retired: boolean; strava_id?: string } + interface GearReplacement { id: string; date: string; note?: string } + interface GearPart { id: string; name: string; threshold_km?: number; replacements: GearReplacement[] } + interface GearItem { id: string; name: string; type: string; retired: boolean; strava_id?: string; parts?: GearPart[] } + let gearItems: GearItem[] = []; let gearLoading = false; let gearFetched = false; @@ -64,9 +67,33 @@ let gearEditType = 'bike'; let gearEditRetired = false; let gearSaving = false; + let gearExpandedId: string | null = null; + + // Part add form state (keyed by gear item id) + let partAddName: Record = {}; + let partAddThreshold: Record = {}; + let partAdding: Record = {}; + let partEditId: string | null = null; // "gearId/partId" + let partEditName = ''; + let partEditThreshold = ''; + let partSaving = false; + let replacingPartId: string | null = null; // "gearId/partId" + let replacementNote = ''; + let replacementDate = ''; const GEAR_ICONS: Record = { bike: '🚲', shoes: '👟', skis: '🎿', other: '⚙️' }; + const PART_SUGGESTIONS: Record = { + bike: { name: 'chain', threshold_km: 3000 }, + shoes: { name: 'running shoes', threshold_km: 750 }, + }; + + const PART_DEFAULTS: Record = { + chain: 3000, cassette: 8000, + 'tyre front': 4500, 'tyre rear': 3500, + 'brake pads': 3000, 'running shoes': 750, + }; + async function gearFetch() { if (gearFetched) return; gearLoading = true; gearFetched = true; @@ -82,8 +109,7 @@ gearAdding = true; gearError = null; try { const r = await fetch('/api/gear', { - method: 'POST', - credentials: 'include', + method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: gearAddName.trim(), type: gearAddType }), }); @@ -103,8 +129,7 @@ gearSaving = true; gearError = null; try { const r = await fetch(`/api/gear/${gearEditId}`, { - method: 'PATCH', - credentials: 'include', + method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: gearEditName.trim(), type: gearEditType, retired: gearEditRetired }), }); @@ -121,11 +146,143 @@ gearError = null; try { const r = await fetch(`/api/gear/${id}`, { method: 'DELETE', credentials: 'include' }); - if (r.ok) gearItems = gearItems.filter(g => g.id !== id); + if (r.ok) { gearItems = gearItems.filter(g => g.id !== id); if (gearExpandedId === id) gearExpandedId = null; } else { const d = await r.json(); gearError = d.detail ?? 'Could not delete.'; } } catch { gearError = 'Could not reach server.'; } } + function gearToggle(id: string) { + gearExpandedId = gearExpandedId === id ? null : id; + } + + // ── Parts ────────────────────────────────────────────────────────────────── + + function partDistanceKm(gearName: string, part: GearPart): number { + const lastRep = part.replacements.at(-1); + if (!lastRep) return (gearDistances[gearName] ?? 0) / 1000; + return allActivities + .filter(a => a.gear === gearName && a.started_at > lastRep.date) + .reduce((s, a) => s + (a.distance_m ?? 0), 0) / 1000; + } + + function partColor(km: number, threshold: number | undefined): string { + if (!threshold) return 'text-zinc-400'; + const pct = km / threshold; + if (pct >= 1) return 'text-red-400'; + if (pct >= 0.7) return 'text-yellow-400'; + return 'text-green-400'; + } + + function partDots(item: GearItem): string[] { + return (item.parts ?? []).map(p => { + const km = partDistanceKm(item.name, p); + return partColor(km, p.threshold_km).replace('text-', ''); + }); + } + + async function partAddSuggestion(item: GearItem) { + const s = PART_SUGGESTIONS[item.type]; + if (!s) return; + await partAdd(item, s.name, s.threshold_km); + } + + async function partAdd(item: GearItem, nameOverride?: string, thresholdOverride?: number) { + const gid = item.id; + const name = (nameOverride ?? partAddName[gid] ?? '').trim(); + if (!name) return; + const rawThreshold = thresholdOverride ?? (partAddThreshold[gid] ? parseFloat(partAddThreshold[gid]) : PART_DEFAULTS[name.toLowerCase()]); + partAdding = { ...partAdding, [gid]: true }; + try { + const body: Record = { name }; + if (rawThreshold) body.threshold_km = rawThreshold; + const r = await fetch(`/api/gear/${gid}/parts`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const d = await r.json(); + if (r.ok) { + gearItems = gearItems.map(g => g.id === gid ? { ...g, parts: [...(g.parts ?? []), d.part] } : g); + partAddName = { ...partAddName, [gid]: '' }; + partAddThreshold = { ...partAddThreshold, [gid]: '' }; + } else gearError = d.detail ?? 'Could not add part.'; + } catch { gearError = 'Could not reach server.'; } + partAdding = { ...partAdding, [gid]: false }; + } + + function partStartEdit(gid: string, part: GearPart) { + partEditId = `${gid}/${part.id}`; + partEditName = part.name; + partEditThreshold = part.threshold_km != null ? String(part.threshold_km) : ''; + } + + async function partSaveEdit(gid: string, pid: string) { + if (!partEditName.trim()) return; + partSaving = true; + try { + const body: Record = { name: partEditName.trim() }; + if (partEditThreshold) body.threshold_km = parseFloat(partEditThreshold); + const r = await fetch(`/api/gear/${gid}/parts/${pid}`, { + method: 'PATCH', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const d = await r.json(); + if (r.ok) { + gearItems = gearItems.map(g => g.id === gid + ? { ...g, parts: (g.parts ?? []).map(p => p.id === pid ? d.part : p) } + : g); + partEditId = null; + } + } catch { /* ignore */ } + partSaving = false; + } + + async function partDelete(gid: string, pid: string) { + try { + const r = await fetch(`/api/gear/${gid}/parts/${pid}`, { method: 'DELETE', credentials: 'include' }); + if (r.ok) gearItems = gearItems.map(g => g.id === gid ? { ...g, parts: (g.parts ?? []).filter(p => p.id !== pid) } : g); + } catch { /* ignore */ } + } + + function startReplacement(gid: string, pid: string) { + replacingPartId = `${gid}/${pid}`; + replacementNote = ''; + replacementDate = new Date().toISOString().slice(0, 10); + } + + async function saveReplacement(gid: string, pid: string) { + try { + const body: Record = { date: replacementDate }; + if (replacementNote.trim()) body.note = replacementNote.trim(); + const r = await fetch(`/api/gear/${gid}/parts/${pid}/replacements`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const d = await r.json(); + if (r.ok) { + gearItems = gearItems.map(g => g.id === gid ? { + ...g, parts: (g.parts ?? []).map(p => p.id === pid + ? { ...p, replacements: [...p.replacements, d.replacement] } + : p) + } : g); + replacingPartId = null; + } + } catch { /* ignore */ } + } + + async function deleteReplacement(gid: string, pid: string, rid: string) { + try { + const r = await fetch(`/api/gear/${gid}/parts/${pid}/replacements/${rid}`, { method: 'DELETE', credentials: 'include' }); + if (r.ok) gearItems = gearItems.map(g => g.id === gid ? { + ...g, parts: (g.parts ?? []).map(p => p.id === pid + ? { ...p, replacements: p.replacements.filter(rep => rep.id !== rid) } + : p) + } : g); + } catch { /* ignore */ } + } + async function toggleSegment(id: string) { if (expandedId === id) { expandedId = null; return; } expandedId = id; @@ -487,25 +644,174 @@ >Cancel {:else} -
- {GEAR_ICONS[item.type] ?? '⚙️'} - {item.name} - {#if gearDistances[item.name] > 0} - {formatDistance(gearDistances[item.name])} - {/if} - {#if item.retired} - Retired - {/if} + +
- + class="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-zinc-800/40 transition-colors" + on:click={() => gearToggle(item.id)} + aria-expanded={gearExpandedId === item.id} + > + {GEAR_ICONS[item.type] ?? '⚙️'} + {item.name} + + {#each partDots(item) as dotColor} + + {/each} + {#if gearDistances[item.name] > 0} + {formatDistance(gearDistances[item.name])} + {/if} + {#if item.retired} + Retired + {/if} + {gearExpandedId === item.id ? '▲' : '▼'} + + + + {#if gearExpandedId === item.id} +
+ + + {#if (item.parts ?? []).length > 0} +
+ {#each (item.parts ?? []) as part (part.id)} + {#if partEditId === `${item.id}/${part.id}`} + +
+ + + + +
+ {:else} +
+ +
+ {part.name} + {#if replacingPartId === `${item.id}/${part.id}`} + + + + + + {:else} + {@const km = partDistanceKm(item.name, part)} + + {km.toFixed(0)} km{part.threshold_km ? ` / ${part.threshold_km}` : ''} + + + + + {/if} +
+ + {#if part.replacements.length > 0} +
+ {#each part.replacements.slice().reverse().slice(0, 3) as rep (rep.id)} +
+ {rep.date} + {#if rep.note}{rep.note}{/if} + +
+ {/each} +
+ {/if} +
+ {/if} + {/each} +
+ {:else if PART_SUGGESTIONS[item.type]} + +

+ Track part wear? + +

+ {/if} + + +
+ e.key === 'Enter' && partAdd(item)} + /> + + +
+ + +
+ + +
+ +
+ {/if}
{/if} {/each}