Files
bincio-activity/site/src/components/AthleteView.svelte
T
Davide Scaini 08e8e54c36 Power curve: show record holder in tooltip and add records table
Find the activity that holds each MMP record by scanning per-activity
mmp arrays. Activity title appears in the chart hover tooltip. A table
below the chart lists every duration with the record watts, activity
title (linked), and date. The table has its own all-time/365d/90d toggle
independent of the chart overlays.
2026-05-16 22:25:30 +02:00

419 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
import MmpChart from './MmpChart.svelte';
import RecordsView from './RecordsView.svelte';
import AthleteDrawer from './AthleteDrawer.svelte';
import Explore from './Explore.svelte';
import NerdCorner from './NerdCorner.svelte';
import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format';
import { loadIndex, loadAthlete } from '../lib/dataloader';
export let base: string = '/';
/** Explicit index URL for multi-user per-user pages (user's shard). */
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[] = [];
let allActivities: ActivitySummary[] = [];
let loading = true;
let error: string | null = null;
let drawerOpen = false;
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
let activeTab: Tab = 'power';
let mounted = false;
let isOwner = false;
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';
$: if (mounted) {
const params = new URLSearchParams(window.location.search);
if (activeTab === 'power') params.delete('tab'); else params.set('tab', activeTab);
const qs = params.toString();
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
}
$: if (activeTab === 'segments' && segmentsHandle && !segmentsFetched && !segmentsLoading) {
segmentsLoading = true;
segmentsFetched = true;
fetch(`/api/users/${segmentsHandle}/segment_summary`)
.then(r => r.ok ? r.json() : [])
.then(d => { segmentSummary = d; })
.catch(() => {})
.finally(() => { segmentsLoading = false; });
}
onMount(async () => {
const w = window as any;
if (w.__bincioMe !== undefined) {
isOwner = w.__bincioMe === handle;
} else {
// Multi-user: __bincioMe is set asynchronously by /api/me in Base.astro.
// Listen for the event so owner-only tabs appear without a hard refresh.
window.addEventListener('bincio:me', (e: Event) => {
isOwner = (e as CustomEvent<string>).detail === handle;
}, { once: true });
}
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd'];
const rawTab = new URLSearchParams(window.location.search).get('tab');
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
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),
loadIndex(import.meta.env.BASE_URL, indexUrl || undefined),
]);
// Static file may not exist yet if the background rebuild hasn't finished — fall back to API
let resolvedAthlete = athleteData as AthleteJson | null;
if (!resolvedAthlete && editEnabled) {
try {
const r = await fetch('/api/athlete', { credentials: 'include' });
if (r.ok) resolvedAthlete = await r.json() as AthleteJson;
} catch { /* ignore */ }
}
athlete = resolvedAthlete;
allActivities = index.activities.filter(a => !isUnlisted(a.privacy));
activities = allActivities.filter(a => a.mmp);
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
});
async function onSaved() {
// Try static file first; fall back to API (works before the background rebuild finishes)
const staticUrl = athleteUrl || `${import.meta.env.BASE_URL}data/athlete.json`;
let res = await fetch(`${staticUrl}?t=${Date.now()}`);
if (!res.ok) res = await fetch('/api/athlete', { credentials: 'include' });
if (res.ok) athlete = await res.json() as AthleteJson;
drawerOpen = false;
}
function fmtZone(zones: [number, number][], i: number): string {
const [lo, hi] = zones[i];
return hi >= 9000 ? `${lo}+ W` : `${lo}${hi} W`;
}
function fmtHrZone(zones: [number, number][], i: number): string {
const [lo, hi] = zones[i];
return hi >= 900 ? `${lo}+ bpm` : `${lo}${hi} bpm`;
}
const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean }[] = [
{ key: 'power', label: 'Power Curve' },
{ key: 'records', label: 'Records' },
{ key: 'segments', label: 'Segments' },
{ key: 'profile', label: 'Profile' },
{ key: 'explore', label: 'Explore', ownerOnly: true },
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
];
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
</script>
{#if loading}
<p class="text-zinc-400 text-sm">Loading…</p>
{:else if error}
<p class="text-red-400 text-sm">{error}</p>
{:else if !athlete}
<div class="text-zinc-400 text-sm space-y-3">
<p>No athlete profile yet.</p>
{#if editEnabled}
<button
on:click={() => drawerOpen = true}
class="px-3 py-1.5 text-xs border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white rounded-md transition-colors"
>Create profile</button>
{/if}
</div>
{:else}
<!-- Header row: tabs + edit button -->
<div class="flex items-center justify-between mb-6 border-b border-zinc-800 pb-0">
<nav class="flex gap-0">
{#each TABS as tab}
<button
on:click={() => activeTab = tab.key}
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px"
class:border-blue-500={activeTab === tab.key}
class:text-white={activeTab === tab.key}
class:border-transparent={activeTab !== tab.key}
class:text-zinc-500={activeTab !== tab.key}
class:hover:text-zinc-300={activeTab !== tab.key}
>{tab.label}</button>
{/each}
</nav>
{#if editEnabled}
<button
on:click={() => drawerOpen = true}
class="mb-2 px-3 py-1.5 text-xs border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white rounded-md transition-colors"
>Edit profile</button>
{/if}
</div>
<!-- Power Curve tab -->
{#if activeTab === 'power'}
{#if athlete.power_curve.all_time}
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
<MmpChart {athlete} {activities} {base} />
</div>
{:else}
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data.</p>
{/if}
<!-- Records tab -->
{:else if activeTab === 'records'}
<RecordsView {athlete} {base} />
<!-- Segments tab -->
{:else if activeTab === 'segments'}
<div class="flex items-center justify-between mb-4">
<span></span>
<div class="flex items-center gap-3">
{#if rescanMsg}<span class="text-xs text-zinc-400">{rescanMsg}</span>{/if}
<button
on:click={async () => {
rescanning = true; rescanMsg = null;
try {
const r = await fetch('/api/me/segment-rescan', { method: 'POST', credentials: 'include' });
const d = await r.json();
if (r.ok) {
rescanMsg = `Found ${d.efforts_found} effort${d.efforts_found !== 1 ? 's' : ''}.`;
segmentSummary = [];
segmentsFetched = false;
} else rescanMsg = d.detail ?? 'Rescan failed.';
} catch { rescanMsg = 'Could not reach server.'; }
rescanning = false;
}}
disabled={rescanning}
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors disabled:opacity-40"
>{rescanning ? 'Scanning…' : 'Rescan all activities'}</button>
</div>
</div>
{#if segmentsLoading}
<p class="text-zinc-500 text-sm">Loading…</p>
{:else if segmentSummary.length === 0}
<p class="text-zinc-500 text-sm">No segment efforts yet. Use "Rescan all activities" to detect efforts from existing activities.</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 pr-4">Efforts</th>
</tr>
</thead>
<tbody>
{#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">
<div class="flex items-center gap-1.5">
{#if row.segment.sport}
<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">
<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>
</div>
{/if}
<!-- Nerd Corner tab -->
{:else if activeTab === 'nerd'}
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-4">Year over year</h3>
<NerdCorner activities={allActivities} />
</div>
<!-- Explore tab -->
{:else if activeTab === 'explore'}
<div style="height: calc(100vh - 200px); margin: 0 -1rem -1.5rem;">
<Explore {handle} {base} embedded={true} />
</div>
<!-- Profile tab -->
{:else if activeTab === 'profile'}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-3">
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">Key numbers</h3>
{#if athlete.max_hr}
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Max HR</span>
<span class="text-white font-medium">{athlete.max_hr} bpm</span>
</div>
{/if}
{#if athlete.ftp_w}
<div class="flex justify-between text-sm">
<span class="text-zinc-400">FTP</span>
<span class="text-white font-medium">{athlete.ftp_w} W</span>
</div>
{/if}
{#if !athlete.max_hr && !athlete.ftp_w}
<p class="text-zinc-500 text-sm">Set <code>athlete.max_hr</code> and <code>athlete.ftp_w</code> in your config, or use Edit profile.</p>
{/if}
</div>
{#if athlete.hr_zones}
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-2">
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">HR Zones</h3>
{#each athlete.hr_zones as _zone, i}
<div class="flex justify-between items-center text-sm">
<span class="text-zinc-400">Z{i + 1}</span>
<span class="text-white">{fmtHrZone(athlete.hr_zones!, i)}</span>
</div>
{/each}
</div>
{/if}
{#if athlete.power_zones}
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-2">
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">Power Zones</h3>
{#each athlete.power_zones as _zone, i}
<div class="flex justify-between items-center text-sm">
<span class="text-zinc-400">Z{i + 1}</span>
<span class="text-white">{fmtZone(athlete.power_zones!, i)}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
{#if drawerOpen && editEnabled}
<AthleteDrawer
{editUrl}
on:close={() => drawerOpen = false}
on:saved={onSaved}
/>
{/if}