Segments Phase 4: detail page, activity efforts, athlete tab, new APIs

New API endpoints:
- GET /api/segments/{id} — single segment metadata
- GET /api/activities/{id}/segment_efforts — efforts for an activity (auth)
- GET /api/users/{handle}/segment_summary — public best time + count per segment

New components:
- SegmentDetail.svelte — map + metadata + effort table (with PR/Δ) + rescan button
- SegmentsPage.svelte — URL router: shows detail when /segments/{id}/, list otherwise

Updated:
- segments/index.astro — now uses SegmentsPage router
- nginx-activity.conf — add /segments/ try_files rule for client-side routing
- ActivityDetail.svelte — segment efforts block below laps
- AthleteView.svelte — Segments tab with best time + effort count per segment
- format.ts — add formatElapsed() for compact m:ss display
This commit is contained in:
Davide Scaini
2026-05-13 08:09:24 +02:00
parent c7f0013e57
commit f2075e29d2
8 changed files with 516 additions and 12 deletions
+83
View File
@@ -2437,6 +2437,25 @@ async def get_segments(
} for s in segs])
@app.get("/api/segments/{segment_id}")
async def get_segment(segment_id: str) -> JSONResponse:
"""Return metadata for a single segment."""
dd = _get_data_dir()
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
raise HTTPException(404, "Segment not found")
return JSONResponse({
"id": seg.id,
"name": seg.name,
"sport": seg.sport,
"polyline": seg.polyline,
"distance_m": seg.distance_m,
"bbox": seg.bbox,
"created_by": seg.created_by,
"created_at": _seg_store._iso(seg.created_at),
})
@app.post("/api/segments")
async def create_segment(
body: CreateSegmentRequest,
@@ -2565,6 +2584,70 @@ async def trigger_detect(
return JSONResponse({"ok": True, "efforts_found": total})
@app.get("/api/activities/{activity_id}/segment_efforts")
async def activity_segment_efforts(
activity_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Return segment efforts that belong to a specific activity for the logged-in user."""
user = _require_user(bincio_session)
dd = _get_data_dir()
efforts_dir = dd / user.handle / "segment_efforts"
result = []
if efforts_dir.exists():
import json as _json
for ef_file in sorted(efforts_dir.glob("*.json")):
seg_id = ef_file.stem
all_efforts = _seg_store.load_efforts(dd, user.handle, seg_id)
matching = [e for e in all_efforts if e.activity_id == activity_id]
if not matching:
continue
seg = _seg_store.load_segment(dd, seg_id)
if not seg:
continue
pr_elapsed = min(e.elapsed_s for e in all_efforts)
for eff in matching:
result.append({
"segment_id": seg.id,
"segment_name": seg.name,
"segment_distance_m": seg.distance_m,
"elapsed_s": eff.elapsed_s,
"pr_elapsed_s": pr_elapsed,
"started_at": _seg_store._iso(eff.started_at),
})
return JSONResponse(result)
@app.get("/api/users/{handle}/segment_summary")
async def user_segment_summary(handle: str) -> JSONResponse:
"""Public endpoint: segments where this user has efforts, with best time and count."""
dd = _get_data_dir()
efforts_dir = dd / handle / "segment_efforts"
result = []
if efforts_dir.exists():
for ef_file in sorted(efforts_dir.glob("*.json")):
seg_id = ef_file.stem
efforts = _seg_store.load_efforts(dd, handle, seg_id)
if not efforts:
continue
seg = _seg_store.load_segment(dd, seg_id)
if not seg:
continue
best = min(efforts, key=lambda e: e.elapsed_s)
result.append({
"segment": {
"id": seg.id,
"name": seg.name,
"sport": seg.sport,
"distance_m": seg.distance_m,
},
"best_elapsed_s": best.elapsed_s,
"effort_count": len(efforts),
})
result.sort(key=lambda x: x["segment"]["name"].lower())
return JSONResponse(result)
# ── Feedback ──────────────────────────────────────────────────────────────────
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}