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
+47 -1
View File
@@ -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 @@
</table>
</div>
{/if}
<!-- Segment efforts -->
{#if segmentEfforts.length > 0}
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div class="px-4 py-3 border-b border-zinc-800">
<h3 class="text-sm font-semibold text-white">Segments</h3>
</div>
<table class="w-full text-sm">
<tbody>
{#each segmentEfforts as hit}
{@const isPR = hit.elapsed_s === hit.pr_elapsed_s}
{@const delta = hit.elapsed_s - hit.pr_elapsed_s}
<tr class="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-2.5">
<a href="{base}segments/{hit.segment_id}/"
class="text-white hover:text-blue-400 transition-colors font-medium">
{hit.segment_name}
</a>
<span class="text-zinc-500 text-xs ml-2">{formatDistance(hit.segment_distance_m)}</span>
</td>
<td class="px-4 py-2.5 font-mono text-white text-right">{formatElapsed(hit.elapsed_s)}</td>
<td class="px-4 py-2.5 text-right text-xs font-medium w-16"
class:text-green-400={isPR}
class:text-zinc-500={!isPR}>
{#if isPR}PR{:else}+{Math.floor(delta/60) > 0 ? `${Math.floor(delta/60)}m${(delta%60).toString().padStart(2,'0')}s` : `${delta}s`}{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
+81 -6
View File
@@ -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' },
];
</script>
@@ -138,6 +175,44 @@
{:else if activeTab === 'records'}
<RecordsView {athlete} {base} />
<!-- Segments tab -->
{:else if activeTab === 'segments'}
{#if segmentsLoading}
<p class="text-zinc-500 text-sm">Loading segments…</p>
{:else if segmentSummary.length === 0}
<p class="text-zinc-500 text-sm">No segment efforts yet.</p>
{:else}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-zinc-800">
<tr class="text-left text-zinc-500 text-xs">
<th class="px-4 py-2">Segment</th>
<th class="px-4 py-2 hidden sm:table-cell">Distance</th>
<th class="px-4 py-2">Best time</th>
<th class="px-4 py-2 text-right">Efforts</th>
</tr>
</thead>
<tbody>
{#each segmentSummary as row}
<tr class="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-2.5">
<a href="{base}segments/{row.segment.id}/"
class="text-white hover:text-blue-400 transition-colors font-medium">
{#if row.segment.sport}
<span class="mr-1.5">{sportIcon(row.segment.sport as any)}</span>
{/if}{row.segment.name}
</a>
</td>
<td class="px-4 py-2.5 text-zinc-400 hidden sm:table-cell">{formatDistance(row.segment.distance_m)}</td>
<td class="px-4 py-2.5 font-mono text-white">{formatElapsed(row.best_elapsed_s)}</td>
<td class="px-4 py-2.5 text-zinc-400 text-right">{row.effort_count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Profile tab -->
{:else if activeTab === 'profile'}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+260
View File
@@ -0,0 +1,260 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { formatDistance, formatDate, formatElapsed, sportIcon } from '../lib/format';
export let base: string = '/';
export let segmentId: string = '';
interface Segment {
id: string;
name: string;
sport: string | null;
distance_m: number;
polyline: [number, number][];
created_by: string;
created_at: string;
}
interface Effort {
activity_id: string;
started_at: string;
elapsed_s: number;
avg_speed_kmh: number | null;
avg_hr_bpm: number | null;
avg_power_w: number | null;
np_power_w: number | null;
}
let segment: Segment | null = null;
let efforts: Effort[] = [];
let loading = true;
let loggedIn = true;
let mapEl: HTMLDivElement;
let map: any;
let mapReady = false;
let retriggering = false;
let retriggerMsg: string | null = null;
$: prElapsed = efforts.length ? Math.min(...efforts.map(e => e.elapsed_s)) : null;
function delta(elapsed: number): number {
return prElapsed != null ? elapsed - prElapsed : 0;
}
function formatDelta(d: number): string {
if (d === 0) return 'PR';
const abs = Math.abs(d);
const m = Math.floor(abs / 60);
const s = abs % 60;
const sign = d > 0 ? '+' : '';
return m > 0 ? `${sign}${m}m${s.toString().padStart(2, '0')}s` : `${sign}${s}s`;
}
onMount(async () => {
const [segRes, effRes] = await Promise.allSettled([
fetch(`/api/segments/${segmentId}`, { credentials: 'include' }),
fetch(`/api/segments/${segmentId}/efforts`, { credentials: 'include' }),
]);
if (segRes.status === 'fulfilled' && segRes.value.ok) {
segment = await segRes.value.json();
}
if (effRes.status === 'fulfilled') {
if (effRes.value.status === 401) {
loggedIn = false;
} else if (effRes.value.ok) {
efforts = await effRes.value.json();
}
}
loading = false;
if (segment) setTimeout(initMap, 0);
});
onDestroy(() => map?.remove());
function initMap() {
if (!mapEl || !segment) return;
const lons = segment.polyline.map(([, lon]) => lon);
const lats = segment.polyline.map(([lat]) => lat);
map = new maplibregl.Map({
container: mapEl,
style: 'https://tiles.openfreemap.org/styles/positron',
bounds: [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
fitBoundsOptions: { padding: 60 },
attributionControl: false,
});
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
map.on('load', () => {
map.addSource('seg', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: segment!.polyline.map(([lat, lon]) => [lon, lat]),
},
},
});
map.addLayer({ id: 'seg-line', type: 'line', source: 'seg',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#f59e0b', 'line-width': 5 } });
// Start/end markers
const start = segment!.polyline[0];
const end = segment!.polyline[segment!.polyline.length - 1];
for (const [pt, label, color] of [[start, 'S', '#22c55e'], [end, 'E', '#ef4444']] as const) {
const el = document.createElement('div');
el.style.cssText = `width:20px;height:20px;border-radius:50%;background:${color};color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)`;
el.textContent = label;
new maplibregl.Marker({ element: el }).setLngLat([pt[1], pt[0]]).addTo(map);
}
mapReady = true;
});
}
async function retrigger() {
retriggering = true;
retriggerMsg = null;
try {
const r = await fetch(`/api/segments/${segmentId}/detect`, {
method: 'POST', credentials: 'include',
});
const d = await r.json();
if (r.ok) {
retriggerMsg = `Found ${d.efforts_found} effort${d.efforts_found !== 1 ? 's' : ''}.`;
// Reload efforts
const er = await fetch(`/api/segments/${segmentId}/efforts`, { credentials: 'include' });
if (er.ok) efforts = await er.json();
} else {
retriggerMsg = d.detail ?? 'Detection failed.';
}
} catch {
retriggerMsg = 'Could not reach server.';
}
retriggering = false;
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<!-- Back -->
<a href="{base}segments/" class="text-zinc-500 hover:text-white transition-colors text-sm">← Segments</a>
{#if loading}
<div class="mt-6 space-y-4">
<div class="h-7 w-48 bg-zinc-800 rounded animate-pulse"></div>
<div class="h-64 bg-zinc-800 rounded-xl animate-pulse"></div>
</div>
{:else if !segment}
<p class="mt-6 text-red-400 text-sm">Segment not found.</p>
{:else}
<!-- Title + metadata -->
<div class="mt-4 mb-4 flex items-start gap-3">
{#if segment.sport}
<span class="text-2xl mt-0.5">{sportIcon(segment.sport as any)}</span>
{/if}
<div>
<h1 class="text-2xl font-bold text-white">{segment.name}</h1>
<p class="text-sm text-zinc-500 mt-0.5">
{formatDistance(segment.distance_m)}
{#if segment.sport} · {segment.sport}{/if}
· by {segment.created_by}
· {formatDate(segment.created_at)}
</p>
</div>
</div>
<!-- Map -->
<div class="rounded-xl overflow-hidden mb-6" style="height: 280px;">
<div bind:this={mapEl} class="w-full h-full"></div>
</div>
<!-- Efforts -->
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-semibold text-white">Your efforts</h2>
{#if loggedIn}
<div class="flex items-center gap-3">
{#if retriggerMsg}
<span class="text-xs text-zinc-400">{retriggerMsg}</span>
{/if}
<button
on:click={retrigger}
disabled={retriggering}
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors disabled:opacity-40"
>{retriggering ? 'Scanning…' : 'Rescan activities'}</button>
</div>
{/if}
</div>
{#if !loggedIn}
<p class="text-zinc-500 text-sm">
<a href="{base}login/" class="text-blue-400 hover:text-blue-300">Log in</a> to see your efforts on this segment.
</p>
{:else if efforts.length === 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-6 text-center">
<p class="text-zinc-500 text-sm">No efforts yet.</p>
<p class="text-zinc-600 text-xs mt-1">Upload an activity that passes through this segment, or use "Rescan activities" to check existing ones.</p>
</div>
{:else}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-zinc-800">
<tr class="text-left text-zinc-500 text-xs">
<th class="px-4 py-2">Date</th>
<th class="px-4 py-2">Time</th>
<th class="px-4 py-2">Δ PR</th>
<th class="px-4 py-2 hidden sm:table-cell">Avg speed</th>
<th class="px-4 py-2 hidden sm:table-cell">Avg HR</th>
<th class="px-4 py-2 hidden md:table-cell">Avg power</th>
<th class="px-4 py-2 hidden md:table-cell">NP</th>
</tr>
</thead>
<tbody>
{#each efforts as e (e.activity_id + e.started_at)}
{@const d = delta(e.elapsed_s)}
{@const isPR = d === 0}
<tr
class="border-b border-zinc-800/50 hover:bg-zinc-800/50 transition-colors"
class:bg-green-950={isPR}
class:hover:bg-green-900={isPR}
>
<td class="px-4 py-2">
<a href="{base}activity/{e.activity_id}/" class="text-blue-400 hover:text-blue-300 transition-colors">
{formatDate(e.started_at)}
</a>
</td>
<td class="px-4 py-2 text-white font-mono">{formatElapsed(e.elapsed_s)}</td>
<td class="px-4 py-2 font-medium text-xs"
class:text-green-400={isPR}
class:text-zinc-400={!isPR}>
{formatDelta(d)}
</td>
<td class="px-4 py-2 text-zinc-300 hidden sm:table-cell">
{e.avg_speed_kmh != null ? `${e.avg_speed_kmh.toFixed(1)} km/h` : '—'}
</td>
<td class="px-4 py-2 text-zinc-300 hidden sm:table-cell">
{e.avg_hr_bpm != null ? `${e.avg_hr_bpm} bpm` : '—'}
</td>
<td class="px-4 py-2 text-zinc-300 hidden md:table-cell">
{e.avg_power_w != null ? `${e.avg_power_w} W` : '—'}
</td>
<td class="px-4 py-2 text-zinc-300 hidden md:table-cell">
{e.np_power_w != null ? `${e.np_power_w} W` : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import { onMount } from 'svelte';
import SegmentsView from './SegmentsView.svelte';
import SegmentDetail from './SegmentDetail.svelte';
export let base: string = '/';
let segmentId: string | null = null;
let mounted = false;
onMount(() => {
// /segments/{id}/ → last non-empty path part is the ID
// /segments/ → last part is 'segments', no detail view
// /segments/new/ → skip (handled by its own page)
const parts = window.location.pathname.replace(/\/$/, '').split('/').filter(Boolean);
const last = parts[parts.length - 1];
if (last && last !== 'segments' && last !== 'new') {
segmentId = last;
}
mounted = true;
});
</script>
{#if mounted}
{#if segmentId}
<SegmentDetail {base} {segmentId} />
{:else}
<div class="flex flex-col" style="height: calc(100vh - 48px);">
<SegmentsView {base} />
</div>
{/if}
{/if}
+10
View File
@@ -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`;
+2 -4
View File
@@ -1,9 +1,7 @@
---
import Base from '../../layouts/Base.astro';
import SegmentsView from '../../components/SegmentsView.svelte';
import SegmentsPage from '../../components/SegmentsPage.svelte';
---
<Base title="Segments — BincioActivity">
<div class="flex flex-col" style="height: calc(100vh - 48px);">
<SegmentsView base={import.meta.env.BASE_URL} client:only="svelte" />
</div>
<SegmentsPage base={import.meta.env.BASE_URL} client:only="svelte" />
</Base>
@@ -36,7 +36,7 @@ const athleteUrl = `${mergedBase}athlete.json`;
</a>
</div>
</div>
<AthleteView {base} {indexUrl} {athleteUrl} client:only="svelte" />
<AthleteView {base} {indexUrl} {athleteUrl} {handle} client:only="svelte" />
</Base>
<script define:vars={{ handle }}>
function applyMeCheck(me) {