Athlete segments tab: link best time to activity; expandable effort list
- best_activity_id now included in segment_summary API response
- Best time is a direct link to the activity that produced it
- Clicking a row expands an inline effort list (lazy-loaded from
/api/segments/{id}/efforts): date linked to activity, time, Δ vs PR
- Clicking again collapses; ▲/▼ chevron shows state
This commit is contained in:
@@ -28,14 +28,36 @@
|
||||
interface SegmentSummaryItem {
|
||||
segment: { id: string; name: string; sport: string | null; distance_m: number };
|
||||
best_elapsed_s: number;
|
||||
best_activity_id: string;
|
||||
effort_count: number;
|
||||
}
|
||||
interface SegmentEffort {
|
||||
activity_id: string;
|
||||
started_at: string;
|
||||
elapsed_s: number;
|
||||
}
|
||||
let segmentSummary: SegmentSummaryItem[] = [];
|
||||
let segmentsLoading = false;
|
||||
let segmentsFetched = false;
|
||||
let segmentsHandle = '';
|
||||
let rescanning = false;
|
||||
let rescanMsg: string | null = null;
|
||||
let expandedId: string | null = null;
|
||||
let effortsBySegment: Record<string, SegmentEffort[]> = {};
|
||||
let loadingEfforts: Record<string, boolean> = {};
|
||||
|
||||
async function toggleSegment(id: string) {
|
||||
if (expandedId === id) { expandedId = null; return; }
|
||||
expandedId = id;
|
||||
if (!effortsBySegment[id] && !loadingEfforts[id]) {
|
||||
loadingEfforts = { ...loadingEfforts, [id]: true };
|
||||
try {
|
||||
const r = await fetch(`/api/segments/${id}/efforts`, { credentials: 'include' });
|
||||
if (r.ok) effortsBySegment = { ...effortsBySegment, [id]: await r.json() };
|
||||
} catch { /* ignore */ }
|
||||
loadingEfforts = { ...loadingEfforts, [id]: false };
|
||||
}
|
||||
}
|
||||
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||
const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true';
|
||||
@@ -216,24 +238,87 @@
|
||||
<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>
|
||||
<th class="px-4 py-2 text-right pr-4">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">
|
||||
{#each segmentSummary as row (row.segment.id)}
|
||||
<!-- Summary row -->
|
||||
<tr
|
||||
class="border-b border-zinc-800/50 hover:bg-zinc-800/40 transition-colors cursor-pointer select-none"
|
||||
class:bg-zinc-800={expandedId === row.segment.id}
|
||||
on:click={() => toggleSegment(row.segment.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={e => e.key === 'Enter' && toggleSegment(row.segment.id)}
|
||||
>
|
||||
<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">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if row.segment.sport}
|
||||
<span class="mr-1.5">{sportIcon(row.segment.sport as any)}</span>
|
||||
{/if}{row.segment.name}
|
||||
</a>
|
||||
<span>{sportIcon(row.segment.sport as any)}</span>
|
||||
{/if}
|
||||
<a href="{base}segments/{row.segment.id}/"
|
||||
class="text-white hover:text-blue-400 transition-colors font-medium"
|
||||
on:click|stopPropagation>
|
||||
{row.segment.name}
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
<td class="px-4 py-2.5">
|
||||
<a href="{base}activity/{row.best_activity_id}/"
|
||||
class="font-mono text-white hover:text-blue-400 transition-colors"
|
||||
on:click|stopPropagation
|
||||
title="Activity where this PR was set">
|
||||
{formatElapsed(row.best_elapsed_s)}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-zinc-400 text-right pr-4">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{row.effort_count}
|
||||
<span class="text-zinc-600 text-xs">{expandedId === row.segment.id ? '▲' : '▼'}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded effort list -->
|
||||
{#if expandedId === row.segment.id}
|
||||
<tr class="border-b border-zinc-800/50">
|
||||
<td colspan="4" class="px-4 pb-3 pt-1">
|
||||
{#if loadingEfforts[row.segment.id]}
|
||||
<p class="text-zinc-500 text-xs py-2">Loading…</p>
|
||||
{:else}
|
||||
{@const efforts = effortsBySegment[row.segment.id] ?? []}
|
||||
{@const pr = efforts.length ? Math.min(...efforts.map(e => e.elapsed_s)) : 0}
|
||||
<div class="rounded-lg overflow-hidden border border-zinc-700/50">
|
||||
<table class="w-full text-xs">
|
||||
<tbody>
|
||||
{#each efforts as e (e.activity_id + e.started_at)}
|
||||
{@const delta = e.elapsed_s - pr}
|
||||
{@const isPR = delta === 0}
|
||||
<tr class="border-b border-zinc-700/30 last:border-0 hover:bg-zinc-700/20 transition-colors"
|
||||
class:bg-green-950={isPR}>
|
||||
<td class="px-3 py-1.5">
|
||||
<a href="{base}activity/{e.activity_id}/"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors">
|
||||
{new Date(e.started_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 font-mono text-white">{formatElapsed(e.elapsed_s)}</td>
|
||||
<td class="px-3 py-1.5 font-medium"
|
||||
class:text-green-400={isPR}
|
||||
class:text-zinc-500={!isPR}>
|
||||
{#if isPR}PR{:else}+{delta < 60 ? `${delta}s` : `${Math.floor(delta/60)}m${(delta%60).toString().padStart(2,'0')}s`}{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user