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}
+
+ |
+
+ {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}
+ |
+
+ {/each}
+
+
+
+{/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}
+
+ {/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}
+
+
+
+
+ | Date |
+ Time |
+ Δ PR |
+ Avg speed |
+ Avg HR |
+ Avg power |
+ NP |
+
+
+
+ {#each efforts as e (e.activity_id + e.started_at)}
+ {@const d = delta(e.elapsed_s)}
+ {@const isPR = d === 0}
+
+ |
+
+ {formatDate(e.started_at)}
+
+ |
+ {formatElapsed(e.elapsed_s)} |
+
+ {formatDelta(d)}
+ |
+
+ {e.avg_speed_kmh != null ? `${e.avg_speed_kmh.toFixed(1)} km/h` : '—'}
+ |
+
+ {e.avg_hr_bpm != null ? `${e.avg_hr_bpm} bpm` : '—'}
+ |
+
+ {e.avg_power_w != null ? `${e.avg_power_w} W` : '—'}
+ |
+
+ {e.np_power_w != null ? `${e.np_power_w} W` : '—'}
+ |
+
+ {/each}
+
+
+
+ {/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`;
-
+