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
+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>