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 @@
+
+
+
{error}
{:else if athlete} + + {#if editUrl} +