diff --git a/bincio/serve/routers/gear.py b/bincio/serve/routers/gear.py index 339656d..946b731 100644 --- a/bincio/serve/routers/gear.py +++ b/bincio/serve/routers/gear.py @@ -58,6 +58,14 @@ async def gear_add( if gear_type not in _GEAR_TYPES: raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}") strava_id = str(body.get("strava_id", "")).strip() or None + weight_g = body.get("weight_g") + if weight_g is not None: + try: + weight_g = int(weight_g) + if weight_g < 0: + raise ValueError + except (TypeError, ValueError): + raise HTTPException(400, "weight_g must be a non-negative integer (grams)") user_dir = deps._get_data_dir() / user.handle items = _load(user_dir) @@ -75,6 +83,8 @@ async def gear_add( } if strava_id: item["strava_id"] = strava_id + if weight_g is not None: + item["weight_g"] = weight_g items.append(item) _save(user_dir, items) @@ -110,6 +120,18 @@ async def gear_update( item["type"] = gear_type if "retired" in body: item["retired"] = bool(body["retired"]) + if "weight_g" in body: + w = body["weight_g"] + if w is None: + item.pop("weight_g", None) + else: + try: + w = int(w) + if w < 0: + raise ValueError + except (TypeError, ValueError): + raise HTTPException(400, "weight_g must be a non-negative integer (grams)") + item["weight_g"] = w items[idx] = item _save(user_dir, items) diff --git a/site/src/components/AthleteDrawer.svelte b/site/src/components/AthleteDrawer.svelte index 6225f7c..be42db3 100644 --- a/site/src/components/AthleteDrawer.svelte +++ b/site/src/components/AthleteDrawer.svelte @@ -13,6 +13,7 @@ let maxHr: number | null = null; let ftpW: number | null = null; + let weightKg: number | null = null; let hrZones: [number, number][] = []; let powerZones: [number, number][] = []; let seasons: { name: string; start: string; end: string }[] = []; @@ -24,8 +25,9 @@ const res = await fetch(`${editUrl}/api/athlete`); if (!res.ok) throw new Error(await res.text()); const d = await res.json(); - maxHr = d.max_hr ?? null; - ftpW = d.ftp_w ?? null; + maxHr = d.max_hr ?? null; + ftpW = d.ftp_w ?? null; + weightKg = d.weight_kg ?? null; hrZones = d.hr_zones ? d.hr_zones.map((z: number[]) => [z[0], z[1]] as [number,number]) : []; powerZones = d.power_zones ? d.power_zones.map((z: number[]) => [z[0], z[1]] as [number,number]) : []; seasons = d.seasons ?? []; @@ -76,6 +78,7 @@ body: JSON.stringify({ max_hr: maxHr, ftp_w: ftpW, + weight_kg: weightKg, hr_zones: hrZones, power_zones: powerZones, seasons, @@ -144,6 +147,14 @@ class="w-full bg-zinc-900 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500" /> + + Body weight (kg) + + diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 820fb25..6eb92c8 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -53,7 +53,7 @@ // Gear tab state 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[] } + interface GearItem { id: string; name: string; type: string; retired: boolean; strava_id?: string; weight_g?: number; parts?: GearPart[] } let gearItems: GearItem[] = []; let gearLoading = false; @@ -66,6 +66,7 @@ let gearEditName = ''; let gearEditType = 'bike'; let gearEditRetired = false; + let gearEditWeightG: number | null = null; let gearSaving = false; let gearExpandedId: string | null = null; @@ -161,7 +162,7 @@ } function gearStartEdit(item: GearItem) { - gearEditId = item.id; gearEditName = item.name; gearEditType = item.type; gearEditRetired = item.retired; + gearEditId = item.id; gearEditName = item.name; gearEditType = item.type; gearEditRetired = item.retired; gearEditWeightG = item.weight_g ?? null; } async function gearSaveEdit() { @@ -171,7 +172,7 @@ const r = await fetch(`/api/gear/${gearEditId}`, { method: 'PATCH', credentials: 'include', 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, weight_g: gearEditWeightG }), }); const d = await r.json(); if (r.ok) { @@ -673,6 +674,14 @@ Retired + + + g + 0} {formatDistance(gearDistances[item.name])} {/if} + {#if item.weight_g} + {item.weight_g >= 1000 ? (item.weight_g / 1000).toFixed(2).replace(/\.?0+$/, '') + ' kg' : item.weight_g + ' g'} + {/if} {#if item.retired} Retired {/if} @@ -910,8 +922,14 @@ {athlete.ftp_w} W {/if} - {#if !athlete.max_hr && !athlete.ftp_w} - Set athlete.max_hr and athlete.ftp_w in your config, or use Edit profile. + {#if athlete.weight_kg} + + Weight + {athlete.weight_kg} kg + + {/if} + {#if !athlete.max_hr && !athlete.ftp_w && !athlete.weight_kg} + No key numbers set yet. Use Edit profile to add Max HR, FTP, and body weight. {/if} diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index 091a021..b6ce085 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -44,6 +44,7 @@ export interface AthleteJson { best_climbs?: BestClimb[]; max_hr?: number; ftp_w?: number; + weight_kg?: number; hr_zones?: [number, number][]; power_zones?: [number, number][]; seasons?: { name: string; start: string; end: string }[];
Set athlete.max_hr and athlete.ftp_w in your config, or use Edit profile.
athlete.max_hr
athlete.ftp_w
No key numbers set yet. Use Edit profile to add Max HR, FTP, and body weight.