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:
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user