From 2cc53dece4a50b6739875633cf9ed37d3999197f Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 30 Mar 2026 10:39:40 +0200 Subject: [PATCH] edit athlete data --- bincio/edit/server.py | 81 +++++++ site/src/components/AthleteDrawer.svelte | 277 +++++++++++++++++++++++ site/src/components/AthleteView.svelte | 30 +++ 3 files changed, 388 insertions(+) create mode 100644 site/src/components/AthleteDrawer.svelte diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 1d1886d..7bddb8d 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -435,6 +435,87 @@ async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONRe return JSONResponse({"ok": True, "filename": dest.name}) +@app.get("/api/athlete") +async def get_athlete() -> JSONResponse: + dd = _get_data_dir() + athlete_path = dd / "athlete.json" + if not athlete_path.exists(): + raise HTTPException(404, "athlete.json not found — run bincio extract first") + + data = json.loads(athlete_path.read_text(encoding="utf-8")) + + # Layer edits/athlete.yaml overrides on top + overrides = _read_athlete_edits(dd) + for key in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"): + if key in overrides: + data[key] = overrides[key] + + return JSONResponse({ + "max_hr": data.get("max_hr"), + "ftp_w": data.get("ftp_w"), + "hr_zones": data.get("hr_zones"), + "power_zones": data.get("power_zones"), + "seasons": data.get("seasons", []), + "gear": data.get("gear", {}), + }) + + +@app.post("/api/athlete") +async def save_athlete(payload: dict[str, Any]) -> JSONResponse: + dd = _get_data_dir() + athlete_path = dd / "athlete.json" + if not athlete_path.exists(): + raise HTTPException(404, "athlete.json not found — run bincio extract first") + + # Write edits/athlete.yaml with validated fields + edits_dir = dd / "edits" + edits_dir.mkdir(exist_ok=True) + overrides: dict[str, Any] = {} + if payload.get("max_hr") is not None: + overrides["max_hr"] = int(payload["max_hr"]) + if payload.get("ftp_w") is not None: + overrides["ftp_w"] = int(payload["ftp_w"]) + if payload.get("hr_zones") is not None: + overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]] + if payload.get("power_zones") is not None: + overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]] + if payload.get("seasons") is not None: + overrides["seasons"] = [ + {"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])} + for s in payload["seasons"] + ] + if payload.get("gear") is not None: + overrides["gear"] = payload["gear"] + + import yaml + (edits_dir / "athlete.yaml").write_text( + yaml.dump(overrides, allow_unicode=True, default_flow_style=False), + encoding="utf-8", + ) + + # Patch athlete.json in-place (preserves power_curve, updated_at, etc.) + data = json.loads(athlete_path.read_text(encoding="utf-8")) + data.update(overrides) + athlete_path.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + + # Re-merge so _merged/athlete.json symlink stays valid + from bincio.render.merge import merge_all + merge_all(dd) + + return JSONResponse({"ok": True}) + + +def _read_athlete_edits(data_dir: Path) -> dict: + path = data_dir / "edits" / "athlete.yaml" + if not path.exists(): + return {} + try: + import yaml + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return {} + + @app.delete("/api/activity/{activity_id}/images/{filename}") async def delete_image(activity_id: str, filename: str) -> JSONResponse: dd = _get_data_dir() diff --git a/site/src/components/AthleteDrawer.svelte b/site/src/components/AthleteDrawer.svelte new file mode 100644 index 0000000..6225f7c --- /dev/null +++ b/site/src/components/AthleteDrawer.svelte @@ -0,0 +1,277 @@ + + + + + + + + + diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 846f40c..7bbaa3f 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -2,11 +2,15 @@ import { onMount } from 'svelte'; import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types'; import MmpChart from './MmpChart.svelte'; + import AthleteDrawer from './AthleteDrawer.svelte'; let athlete: AthleteJson | null = null; let activities: ActivitySummary[] = []; let loading = true; let error: string | null = null; + let drawerOpen = false; + + const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; onMount(async () => { try { @@ -19,6 +23,7 @@ const index: BASIndex = await indexRes.json(); // Only activities with power data contribute to the curve activities = index.activities.filter(a => a.mmp && a.privacy !== 'private'); + } catch (e: any) { error = e.message; } finally { @@ -26,6 +31,13 @@ } }); + async function onSaved() { + // Reload athlete.json after edits are saved + const res = await fetch(`${import.meta.env.BASE_URL}data/athlete.json?t=${Date.now()}`); + if (res.ok) athlete = await res.json(); + drawerOpen = false; + } + function fmtZone(zones: [number, number][], i: number): string { const [lo, hi] = zones[i]; return hi >= 9000 ? `${lo}+ W` : `${lo}–${hi} W`; @@ -42,6 +54,16 @@

{error}

{:else if athlete} + + {#if editUrl} +
+ +
+ {/if} +

Power Curve

@@ -109,3 +131,11 @@
{/if} + +{#if drawerOpen && editUrl} + drawerOpen = false} + on:saved={onSaved} + /> +{/if}