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:
Davide Scaini
2026-05-13 08:40:39 +02:00
parent 59cf99f0af
commit 0ff5473dfd
2 changed files with 98 additions and 12 deletions
+1
View File
@@ -2700,6 +2700,7 @@ async def user_segment_summary(handle: str) -> JSONResponse:
"distance_m": seg.distance_m, "distance_m": seg.distance_m,
}, },
"best_elapsed_s": best.elapsed_s, "best_elapsed_s": best.elapsed_s,
"best_activity_id": best.activity_id,
"effort_count": len(efforts), "effort_count": len(efforts),
}) })
result.sort(key=lambda x: x["segment"]["name"].lower()) result.sort(key=lambda x: x["segment"]["name"].lower())
+94 -9
View File
@@ -28,14 +28,36 @@
interface SegmentSummaryItem { interface SegmentSummaryItem {
segment: { id: string; name: string; sport: string | null; distance_m: number }; segment: { id: string; name: string; sport: string | null; distance_m: number };
best_elapsed_s: number; best_elapsed_s: number;
best_activity_id: string;
effort_count: number; effort_count: number;
} }
interface SegmentEffort {
activity_id: string;
started_at: string;
elapsed_s: number;
}
let segmentSummary: SegmentSummaryItem[] = []; let segmentSummary: SegmentSummaryItem[] = [];
let segmentsLoading = false; let segmentsLoading = false;
let segmentsFetched = false; let segmentsFetched = false;
let segmentsHandle = ''; let segmentsHandle = '';
let rescanning = false; let rescanning = false;
let rescanMsg: string | null = null; 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 editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true'; 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">Segment</th>
<th class="px-4 py-2 hidden sm:table-cell">Distance</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">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> </tr>
</thead> </thead>
<tbody> <tbody>
{#each segmentSummary as row} {#each segmentSummary as row (row.segment.id)}
<tr class="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/50 transition-colors"> <!-- 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"> <td class="px-4 py-2.5">
<a href="{base}segments/{row.segment.id}/" <div class="flex items-center gap-1.5">
class="text-white hover:text-blue-400 transition-colors font-medium">
{#if row.segment.sport} {#if row.segment.sport}
<span class="mr-1.5">{sportIcon(row.segment.sport as any)}</span> <span>{sportIcon(row.segment.sport as any)}</span>
{/if}{row.segment.name} {/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> </a>
</div>
</td> </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 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">
<td class="px-4 py-2.5 text-zinc-400 text-right">{row.effort_count}</td> <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> </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} {/each}
</tbody> </tbody>
</table> </table>