feat: add weight fields to athlete profile and gear items
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-500 mb-1 block">Body weight (kg)</span>
|
||||
<input
|
||||
type="number" min="30" max="200" step="0.1"
|
||||
bind:value={weightKg}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<input type="checkbox" bind:checked={gearEditRetired} class="accent-blue-500" />
|
||||
Retired
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm text-zinc-400">
|
||||
<input
|
||||
type="number" min="0" step="1" placeholder="weight (g)"
|
||||
bind:value={gearEditWeightG}
|
||||
class="w-28 bg-zinc-700 text-white text-sm px-2 py-1 rounded border border-zinc-600 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
<span class="text-xs">g</span>
|
||||
</label>
|
||||
<button
|
||||
on:click={gearSaveEdit}
|
||||
disabled={gearSaving || !gearEditName.trim()}
|
||||
@@ -703,6 +712,9 @@
|
||||
{#if gearDistances[item.name] > 0}
|
||||
<span class="text-xs text-zinc-400">{formatDistance(gearDistances[item.name])}</span>
|
||||
{/if}
|
||||
{#if item.weight_g}
|
||||
<span class="text-xs text-zinc-500">{item.weight_g >= 1000 ? (item.weight_g / 1000).toFixed(2).replace(/\.?0+$/, '') + ' kg' : item.weight_g + ' g'}</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}
|
||||
@@ -910,8 +922,14 @@
|
||||
<span class="text-white font-medium">{athlete.ftp_w} W</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !athlete.max_hr && !athlete.ftp_w}
|
||||
<p class="text-zinc-500 text-sm">Set <code>athlete.max_hr</code> and <code>athlete.ftp_w</code> in your config, or use Edit profile.</p>
|
||||
{#if athlete.weight_kg}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-zinc-400">Weight</span>
|
||||
<span class="text-white font-medium">{athlete.weight_kg} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !athlete.max_hr && !athlete.ftp_w && !athlete.weight_kg}
|
||||
<p class="text-zinc-500 text-sm">No key numbers set yet. Use Edit profile to add Max HR, FTP, and body weight.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
Reference in New Issue
Block a user