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:
Davide Scaini
2026-05-24 13:40:27 +02:00
parent 7db7bf91e0
commit bdee036204
3 changed files with 489 additions and 24 deletions
+158
View File
@@ -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})
@@ -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'
+330 -24
View File
@@ -51,7 +51,10 @@
let loadingEfforts: Record<string, boolean> = {};
// 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<string, string> = {};
let partAddThreshold: Record<string, string> = {};
let partAdding: Record<string, boolean> = {};
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<string, string> = { bike: '🚲', shoes: '👟', skis: '🎿', other: '⚙️' };
const PART_SUGGESTIONS: Record<string, { name: string; threshold_km: number }> = {
bike: { name: 'chain', threshold_km: 3000 },
shoes: { name: 'running shoes', threshold_km: 750 },
};
const PART_DEFAULTS: Record<string, number> = {
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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</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 gearDistances[item.name] > 0}
<span class="text-xs text-zinc-400">{formatDistance(gearDistances[item.name])}</span>
{/if}
{#if item.retired}
<span class="text-xs text-zinc-600 border border-zinc-700 rounded px-1.5 py-0.5">Retired</span>
{/if}
<!-- Gear row header -->
<div class="border-b border-zinc-800 last:border-0">
<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>
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}
>
<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>
<!-- Part status dots -->
{#each partDots(item) as dotColor}
<span class="w-2 h-2 rounded-full bg-current {dotColor.replace('text-', 'text-')}
{dotColor === 'red-400' ? 'text-red-400' : dotColor === 'yellow-400' ? 'text-yellow-400' : 'text-green-400'}"
style="display:inline-block;width:.5rem;height:.5rem;border-radius:9999px;background:currentColor"
></span>
{/each}
{#if gearDistances[item.name] > 0}
<span class="text-xs text-zinc-400">{formatDistance(gearDistances[item.name])}</span>
{/if}
{#if item.retired}
<span class="text-xs text-zinc-600 border border-zinc-700 rounded px-1.5 py-0.5">Retired</span>
{/if}
<span class="text-zinc-600 text-xs ml-1">{gearExpandedId === item.id ? '▲' : '▼'}</span>
</button>
<!-- Expanded section -->
{#if gearExpandedId === item.id}
<div class="px-4 pb-4 pt-1 border-t border-zinc-800 bg-zinc-950/40 space-y-3">
<!-- Parts list -->
{#if (item.parts ?? []).length > 0}
<div class="space-y-1">
{#each (item.parts ?? []) as part (part.id)}
{#if partEditId === `${item.id}/${part.id}`}
<!-- Part edit form -->
<div class="flex flex-wrap gap-2 items-center py-1">
<input
bind:value={partEditName}
class="flex-1 min-w-28 bg-zinc-800 text-white text-xs px-2 py-1 rounded border border-zinc-600 focus:border-blue-500 outline-none"
placeholder="Part name"
/>
<input
bind:value={partEditThreshold}
type="number" min="0"
class="w-24 bg-zinc-800 text-white text-xs px-2 py-1 rounded border border-zinc-600 focus:border-blue-500 outline-none"
placeholder="km limit"
/>
<button
on:click={() => partSaveEdit(item.id, part.id)}
disabled={partSaving}
class="text-xs px-2 py-1 rounded bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-40"
>{partSaving ? '…' : 'Save'}</button>
<button
on:click={() => partEditId = null}
class="text-xs px-2 py-1 rounded border border-zinc-700 text-zinc-400 hover:text-white transition-colors"
>Cancel</button>
</div>
{:else}
<div class="space-y-1">
<!-- Part row -->
<div class="flex items-center gap-2 text-sm">
<span class="flex-1 text-zinc-300 text-xs">{part.name}</span>
{#if replacingPartId === `${item.id}/${part.id}`}
<!-- Replacement form inline -->
<input
bind:value={replacementDate}
type="date"
class="bg-zinc-800 text-white text-xs px-2 py-0.5 rounded border border-zinc-600 outline-none"
/>
<input
bind:value={replacementNote}
placeholder="note (optional)"
class="w-28 bg-zinc-800 text-white text-xs px-2 py-0.5 rounded border border-zinc-600 outline-none placeholder:text-zinc-600"
/>
<button
on:click={() => saveReplacement(item.id, part.id)}
class="text-xs px-2 py-0.5 rounded bg-blue-600 hover:bg-blue-500 text-white transition-colors"
>Save</button>
<button
on:click={() => replacingPartId = null}
class="text-xs text-zinc-500 hover:text-white transition-colors"
>Cancel</button>
{:else}
{@const km = partDistanceKm(item.name, part)}
<span class="text-xs font-mono {partColor(km, part.threshold_km)}">
{km.toFixed(0)} km{part.threshold_km ? ` / ${part.threshold_km}` : ''}
</span>
<button
on:click={() => startReplacement(item.id, part.id)}
class="text-xs text-zinc-500 hover:text-blue-400 transition-colors"
title="Log replacement"
>Replaced</button>
<button
on:click={() => partStartEdit(item.id, part)}
class="text-xs text-zinc-600 hover:text-white transition-colors"
>Edit</button>
<button
on:click={() => partDelete(item.id, part.id)}
class="text-xs text-zinc-700 hover:text-red-400 transition-colors"
></button>
{/if}
</div>
<!-- Recent replacements -->
{#if part.replacements.length > 0}
<div class="pl-2 space-y-0.5">
{#each part.replacements.slice().reverse().slice(0, 3) as rep (rep.id)}
<div class="flex items-center gap-2 text-xs text-zinc-600">
<span>{rep.date}</span>
{#if rep.note}<span class="text-zinc-500">{rep.note}</span>{/if}
<button
on:click={() => deleteReplacement(item.id, part.id, rep.id)}
class="hover:text-red-400 transition-colors ml-auto"
aria-label="Delete log entry"
></button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{:else if PART_SUGGESTIONS[item.type]}
<!-- First-part suggestion -->
<p class="text-xs text-zinc-500">
Track part wear?
<button
on:click={() => partAddSuggestion(item)}
class="text-blue-400 hover:text-blue-300 underline transition-colors"
>Add {PART_SUGGESTIONS[item.type].name}</button>
</p>
{/if}
<!-- Add part form -->
<div class="flex flex-wrap gap-2 items-center pt-1 border-t border-zinc-800">
<input
bind:value={partAddName[item.id]}
placeholder="Part name (e.g. chain)"
class="flex-1 min-w-28 bg-zinc-800 text-white text-xs px-2 py-1 rounded border border-zinc-700 focus:border-blue-500 outline-none placeholder:text-zinc-600"
on:keydown={e => e.key === 'Enter' && partAdd(item)}
/>
<input
bind:value={partAddThreshold[item.id]}
type="number" min="0"
placeholder="km limit"
class="w-20 bg-zinc-800 text-white text-xs px-2 py-1 rounded border border-zinc-700 focus:border-blue-500 outline-none placeholder:text-zinc-600"
/>
<button
on:click={() => partAdd(item)}
disabled={partAdding[item.id] || !partAddName[item.id]?.trim()}
class="text-xs px-2 py-1 rounded border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white transition-colors disabled:opacity-40"
>{partAdding[item.id] ? '…' : '+ Part'}</button>
</div>
<!-- Gear item actions -->
<div class="flex gap-3 pt-1">
<button
on:click={() => gearStartEdit(item)}
class="text-xs text-zinc-500 hover:text-white transition-colors"
>Edit gear</button>
<button
on:click={() => gearDelete(item.id)}
class="text-xs text-zinc-600 hover:text-red-400 transition-colors"
>Delete gear</button>
</div>
</div>
{/if}
</div>
{/if}
{/each}