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:
@@ -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>
|
||||
Reference in New Issue
Block a user