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