From 0ff5473dfd566ca0f068c252d0344ce566b9f022 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 13 May 2026 08:40:39 +0200 Subject: [PATCH] Athlete segments tab: link best time to activity; expandable effort list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bincio/serve/server.py | 5 +- site/src/components/AthleteView.svelte | 105 ++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 12bf2cb..6af0551 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -2699,8 +2699,9 @@ async def user_segment_summary(handle: str) -> JSONResponse: "sport": seg.sport, "distance_m": seg.distance_m, }, - "best_elapsed_s": best.elapsed_s, - "effort_count": len(efforts), + "best_elapsed_s": best.elapsed_s, + "best_activity_id": best.activity_id, + "effort_count": len(efforts), }) result.sort(key=lambda x: x["segment"]["name"].lower()) return JSONResponse(result) diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 32d7823..c6975d4 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -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 = {}; + let loadingEfforts: Record = {}; + + 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 @@ Segment Distance Best time - Efforts + Efforts - {#each segmentSummary as row} - + {#each segmentSummary as row (row.segment.id)} + + toggleSegment(row.segment.id)} + role="button" + tabindex="0" + on:keydown={e => e.key === 'Enter' && toggleSegment(row.segment.id)} + > - + {formatDistance(row.segment.distance_m)} - {formatElapsed(row.best_elapsed_s)} - {row.effort_count} + + + {formatElapsed(row.best_elapsed_s)} + + + + + {row.effort_count} + {expandedId === row.segment.id ? '▲' : '▼'} + + + + + {#if expandedId === row.segment.id} + + + {#if loadingEfforts[row.segment.id]} +

Loading…

+ {:else} + {@const efforts = effortsBySegment[row.segment.id] ?? []} + {@const pr = efforts.length ? Math.min(...efforts.map(e => e.elapsed_s)) : 0} +
+ + + {#each efforts as e (e.activity_id + e.started_at)} + {@const delta = e.elapsed_s - pr} + {@const isPR = delta === 0} + + + + + + {/each} + +
+ + {new Date(e.started_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + + {formatElapsed(e.elapsed_s)} + {#if isPR}PR{:else}+{delta < 60 ? `${delta}s` : `${Math.floor(delta/60)}m${(delta%60).toString().padStart(2,'0')}s`}{/if} +
+
+ {/if} + + + {/if} {/each}