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)
|
_save(user_dir, items)
|
||||||
return JSONResponse({"ok": True})
|
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,
|
avg_power_w: d.avg_power_w ?? null,
|
||||||
mmp: d.mmp ?? null,
|
mmp: d.mmp ?? null,
|
||||||
source: d.source ?? null,
|
source: d.source ?? null,
|
||||||
|
gear: d.gear ?? null,
|
||||||
privacy: d.privacy ?? 'public',
|
privacy: d.privacy ?? 'public',
|
||||||
detail_url: detailUrl,
|
detail_url: detailUrl,
|
||||||
track_url: d.bbox && d.privacy !== 'no_gps'
|
track_url: d.bbox && d.privacy !== 'no_gps'
|
||||||
|
|||||||
@@ -51,7 +51,10 @@
|
|||||||
let loadingEfforts: Record<string, boolean> = {};
|
let loadingEfforts: Record<string, boolean> = {};
|
||||||
|
|
||||||
// Gear tab state
|
// 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 gearItems: GearItem[] = [];
|
||||||
let gearLoading = false;
|
let gearLoading = false;
|
||||||
let gearFetched = false;
|
let gearFetched = false;
|
||||||
@@ -64,9 +67,33 @@
|
|||||||
let gearEditType = 'bike';
|
let gearEditType = 'bike';
|
||||||
let gearEditRetired = false;
|
let gearEditRetired = false;
|
||||||
let gearSaving = 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 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() {
|
async function gearFetch() {
|
||||||
if (gearFetched) return;
|
if (gearFetched) return;
|
||||||
gearLoading = true; gearFetched = true;
|
gearLoading = true; gearFetched = true;
|
||||||
@@ -82,8 +109,7 @@
|
|||||||
gearAdding = true; gearError = null;
|
gearAdding = true; gearError = null;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/gear', {
|
const r = await fetch('/api/gear', {
|
||||||
method: 'POST',
|
method: 'POST', credentials: 'include',
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: gearAddName.trim(), type: gearAddType }),
|
body: JSON.stringify({ name: gearAddName.trim(), type: gearAddType }),
|
||||||
});
|
});
|
||||||
@@ -103,8 +129,7 @@
|
|||||||
gearSaving = true; gearError = null;
|
gearSaving = true; gearError = null;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/gear/${gearEditId}`, {
|
const r = await fetch(`/api/gear/${gearEditId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH', credentials: 'include',
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: gearEditName.trim(), type: gearEditType, retired: gearEditRetired }),
|
body: JSON.stringify({ name: gearEditName.trim(), type: gearEditType, retired: gearEditRetired }),
|
||||||
});
|
});
|
||||||
@@ -121,11 +146,143 @@
|
|||||||
gearError = null;
|
gearError = null;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/gear/${id}`, { method: 'DELETE', credentials: 'include' });
|
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.'; }
|
else { const d = await r.json(); gearError = d.detail ?? 'Could not delete.'; }
|
||||||
} catch { gearError = 'Could not reach server.'; }
|
} 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) {
|
async function toggleSegment(id: string) {
|
||||||
if (expandedId === id) { expandedId = null; return; }
|
if (expandedId === id) { expandedId = null; return; }
|
||||||
expandedId = id;
|
expandedId = id;
|
||||||
@@ -487,25 +644,174 @@
|
|||||||
>Cancel</button>
|
>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="px-4 py-3 border-b border-zinc-800 flex items-center gap-3 last:border-0">
|
<!-- Gear row header -->
|
||||||
<span class="text-lg leading-none">{GEAR_ICONS[item.type] ?? '⚙️'}</span>
|
<div class="border-b border-zinc-800 last:border-0">
|
||||||
<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}
|
|
||||||
<button
|
<button
|
||||||
on:click={() => gearStartEdit(item)}
|
class="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-zinc-800/40 transition-colors"
|
||||||
class="text-xs text-zinc-500 hover:text-white transition-colors"
|
on:click={() => gearToggle(item.id)}
|
||||||
aria-label="Edit {item.name}"
|
aria-expanded={gearExpandedId === item.id}
|
||||||
>Edit</button>
|
>
|
||||||
<button
|
<span class="text-lg leading-none">{GEAR_ICONS[item.type] ?? '⚙️'}</span>
|
||||||
on:click={() => gearDelete(item.id)}
|
<span class="flex-1 text-sm text-white" class:text-zinc-500={item.retired}>{item.name}</span>
|
||||||
class="text-xs text-zinc-600 hover:text-red-400 transition-colors"
|
<!-- Part status dots -->
|
||||||
aria-label="Delete {item.name}"
|
{#each partDots(item) as dotColor}
|
||||||
>Delete</button>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user