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:
@@ -2699,8 +2699,9 @@ async def user_segment_summary(handle: str) -> JSONResponse:
|
|||||||
"sport": seg.sport,
|
"sport": seg.sport,
|
||||||
"distance_m": seg.distance_m,
|
"distance_m": seg.distance_m,
|
||||||
},
|
},
|
||||||
"best_elapsed_s": best.elapsed_s,
|
"best_elapsed_s": best.elapsed_s,
|
||||||
"effort_count": len(efforts),
|
"best_activity_id": best.activity_id,
|
||||||
|
"effort_count": len(efforts),
|
||||||
})
|
})
|
||||||
result.sort(key=lambda x: x["segment"]["name"].lower())
|
result.sort(key=lambda x: x["segment"]["name"].lower())
|
||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user