08e8e54c36
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.
419 lines
17 KiB
Svelte
419 lines
17 KiB
Svelte
<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}
|