From f2075e29d2f2ccfb570c44f44e15448dd533f6a0 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 13 May 2026 08:09:24 +0200 Subject: [PATCH] Segments Phase 4: detail page, activity efforts, athlete tab, new APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bincio/serve/server.py | 83 ++++++ site/src/components/ActivityDetail.svelte | 48 +++- site/src/components/AthleteView.svelte | 87 +++++- site/src/components/SegmentDetail.svelte | 260 ++++++++++++++++++ site/src/components/SegmentsPage.svelte | 32 +++ site/src/lib/format.ts | 10 + site/src/pages/segments/index.astro | 6 +- site/src/pages/u/[handle]/athlete/index.astro | 2 +- 8 files changed, 516 insertions(+), 12 deletions(-) create mode 100644 site/src/components/SegmentDetail.svelte create mode 100644 site/src/components/SegmentsPage.svelte diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 1e20b14..76a9651 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -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"} diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index ad05f55..66f1105 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -3,7 +3,7 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; import type { ActivitySummary, ActivityDetail, AthleteZones, Timeseries } from '../lib/types'; - import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format'; + import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, formatElapsed, sportIcon, sportLabel, sportColor } from '../lib/format'; import ActivityMap from './ActivityMap.svelte'; import ActivityCharts from './ActivityCharts.svelte'; import EditDrawer from './EditDrawer.svelte'; @@ -27,6 +27,15 @@ let editOpen = false; let lightboxIndex: number | null = null; + interface SegmentEffortHit { + segment_id: string; + segment_name: string; + segment_distance_m: number; + elapsed_s: number; + pr_elapsed_s: number; + } + let segmentEfforts: SegmentEffortHit[] = []; + // Local overrides applied immediately after a save (no re-fetch needed) let localTitle = ''; let localDescription = ''; @@ -38,6 +47,11 @@ .then(u => { if (u?.handle) currentHandle = u.handle; }) .catch(() => {}); + fetch(`/api/activities/${activity.id}/segment_efforts`, { credentials: 'include' }) + .then(r => r.ok ? r.json() : []) + .then(d => { segmentEfforts = d; }) + .catch(() => {}); + try { detail = await loadActivity(activity.id, activity.detail_url ?? '', base); if (!detail) throw new Error('Activity not found'); @@ -373,3 +387,35 @@ {/if} + + +{#if segmentEfforts.length > 0} +
+
+

Segments

+
+ + + {#each segmentEfforts as hit} + {@const isPR = hit.elapsed_s === hit.pr_elapsed_s} + {@const delta = hit.elapsed_s - hit.pr_elapsed_s} + + + + + + {/each} + +
+ + {hit.segment_name} + + {formatDistance(hit.segment_distance_m)} + {formatElapsed(hit.elapsed_s)} + {#if isPR}PR{:else}+{Math.floor(delta/60) > 0 ? `${Math.floor(delta/60)}m${(delta%60).toString().padStart(2,'0')}s` : `${delta}s`}{/if} +
+
+{/if} diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 8fd42c8..597f978 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -4,7 +4,7 @@ import MmpChart from './MmpChart.svelte'; import RecordsView from './RecordsView.svelte'; import AthleteDrawer from './AthleteDrawer.svelte'; - import { isUnlisted } from '../lib/format'; + import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format'; import { loadIndex, loadAthlete } from '../lib/dataloader'; export let base: string = '/'; @@ -12,6 +12,8 @@ export let indexUrl: string = ''; /** Explicit athlete.json URL for multi-user per-user pages. */ export let athleteUrl: string = ''; + /** Handle whose segment summary to show. Parsed from athleteUrl if blank. */ + export let handle: string = ''; let athlete: AthleteJson | null = null; let activities: ActivitySummary[] = []; @@ -19,10 +21,19 @@ let error: string | null = null; let drawerOpen = false; - type Tab = 'power' | 'records' | 'profile'; + type Tab = 'power' | 'records' | 'segments' | 'profile'; let activeTab: Tab = 'power'; let mounted = false; + interface SegmentSummaryItem { + segment: { id: string; name: string; sport: string | null; distance_m: number }; + best_elapsed_s: number; + effort_count: number; + } + let segmentSummary: SegmentSummaryItem[] = []; + let segmentsLoading = false; + let segmentsHandle = ''; + const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true'; @@ -33,11 +44,36 @@ history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); } + $: if (activeTab === 'segments' && segmentsHandle && segmentSummary.length === 0 && !segmentsLoading) { + segmentsLoading = true; + fetch(`/api/users/${segmentsHandle}/segment_summary`) + .then(r => r.ok ? r.json() : []) + .then(d => { segmentSummary = d; }) + .catch(() => {}) + .finally(() => { segmentsLoading = false; }); + } + onMount(async () => { - const TABS: Tab[] = ['power', 'records', 'profile']; + const TABS: Tab[] = ['power', 'records', 'segments', 'profile']; const rawTab = new URLSearchParams(window.location.search).get('tab'); activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power'; mounted = true; + + // Resolve handle for the segments endpoint + if (handle) { + segmentsHandle = handle; + } else if (athleteUrl) { + // Parse from e.g. "/data/ccbas/_merged/athlete.json" + const m = athleteUrl.match(/\/data\/([^/]+)\//); + if (m) segmentsHandle = m[1]; + } + if (!segmentsHandle) { + // Fall back to /api/me for the self-athlete page + try { + const r = await fetch('/api/me', { credentials: 'include' }); + if (r.ok) { const u = await r.json(); segmentsHandle = u.handle ?? ''; } + } catch { /* ignore */ } + } try { const [athleteData, index] = await Promise.all([ loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined), @@ -79,9 +115,10 @@ } const TABS: { key: Tab; label: string }[] = [ - { key: 'power', label: 'Power Curve' }, - { key: 'records', label: 'Records' }, - { key: 'profile', label: 'Profile' }, + { key: 'power', label: 'Power Curve' }, + { key: 'records', label: 'Records' }, + { key: 'segments', label: 'Segments' }, + { key: 'profile', label: 'Profile' }, ]; @@ -138,6 +175,44 @@ {:else if activeTab === 'records'} + + {:else if activeTab === 'segments'} + {#if segmentsLoading} +

Loading segments…

+ {:else if segmentSummary.length === 0} +

No segment efforts yet.

+ {:else} +
+ + + + + + + + + + + {#each segmentSummary as row} + + + + + + + {/each} + +
SegmentBest timeEfforts
+ + {#if row.segment.sport} + {sportIcon(row.segment.sport as any)} + {/if}{row.segment.name} + + {formatElapsed(row.best_elapsed_s)}{row.effort_count}
+
+ {/if} + {:else if activeTab === 'profile'}
diff --git a/site/src/components/SegmentDetail.svelte b/site/src/components/SegmentDetail.svelte new file mode 100644 index 0000000..d8afe1e --- /dev/null +++ b/site/src/components/SegmentDetail.svelte @@ -0,0 +1,260 @@ + + +
+ + ← Segments + + {#if loading} +
+
+
+
+ + {:else if !segment} +

Segment not found.

+ + {:else} + +
+ {#if segment.sport} + {sportIcon(segment.sport as any)} + {/if} +
+

{segment.name}

+

+ {formatDistance(segment.distance_m)} + {#if segment.sport} · {segment.sport}{/if} + · by {segment.created_by} + · {formatDate(segment.created_at)} +

+
+
+ + +
+
+
+ + +
+

Your efforts

+ {#if loggedIn} +
+ {#if retriggerMsg} + {retriggerMsg} + {/if} + +
+ {/if} +
+ + {#if !loggedIn} +

+ Log in to see your efforts on this segment. +

+ + {:else if efforts.length === 0} +
+

No efforts yet.

+

Upload an activity that passes through this segment, or use "Rescan activities" to check existing ones.

+
+ + {:else} +
+ + + + + + + + + + + + + + {#each efforts as e (e.activity_id + e.started_at)} + {@const d = delta(e.elapsed_s)} + {@const isPR = d === 0} + + + + + + + + + + {/each} + +
DateTimeΔ PR
+ + {formatDate(e.started_at)} + + {formatElapsed(e.elapsed_s)} + {formatDelta(d)} +
+
+ {/if} + {/if} +
diff --git a/site/src/components/SegmentsPage.svelte b/site/src/components/SegmentsPage.svelte new file mode 100644 index 0000000..919be25 --- /dev/null +++ b/site/src/components/SegmentsPage.svelte @@ -0,0 +1,32 @@ + + +{#if mounted} + {#if segmentId} + + {:else} +
+ +
+ {/if} +{/if} diff --git a/site/src/lib/format.ts b/site/src/lib/format.ts index 61531d7..2632ddb 100644 --- a/site/src/lib/format.ts +++ b/site/src/lib/format.ts @@ -26,6 +26,16 @@ export function formatDuration(s: number | null): string { return `${m}m ${sec.toString().padStart(2, '0')}s`; } +/** Compact m:ss or h:mm:ss for segment effort tables. */ +export function formatElapsed(s: number): string { + s = Math.floor(s); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; + return `${m}:${sec.toString().padStart(2, '0')}`; +} + export function formatSpeed(kmh: number | null): string { if (kmh == null) return '—'; return `${kmh.toFixed(1)} km/h`; diff --git a/site/src/pages/segments/index.astro b/site/src/pages/segments/index.astro index 0b1fa10..4c05821 100644 --- a/site/src/pages/segments/index.astro +++ b/site/src/pages/segments/index.astro @@ -1,9 +1,7 @@ --- import Base from '../../layouts/Base.astro'; -import SegmentsView from '../../components/SegmentsView.svelte'; +import SegmentsPage from '../../components/SegmentsPage.svelte'; --- -
- -
+ diff --git a/site/src/pages/u/[handle]/athlete/index.astro b/site/src/pages/u/[handle]/athlete/index.astro index 705f7bf..b39cf7c 100644 --- a/site/src/pages/u/[handle]/athlete/index.astro +++ b/site/src/pages/u/[handle]/athlete/index.astro @@ -36,7 +36,7 @@ const athleteUrl = `${mergedBase}athlete.json`;
- +