174 lines
6.1 KiB
Svelte
174 lines
6.1 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';
|
||
|
||
export let base: string = '/';
|
||
|
||
let athlete: AthleteJson | null = null;
|
||
let activities: ActivitySummary[] = [];
|
||
let loading = true;
|
||
let error: string | null = null;
|
||
let drawerOpen = false;
|
||
|
||
type Tab = 'power' | 'records' | 'profile';
|
||
let activeTab: Tab = 'power';
|
||
let mounted = false;
|
||
|
||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||
|
||
$: 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);
|
||
}
|
||
|
||
onMount(async () => {
|
||
const TABS: Tab[] = ['power', 'records', 'profile'];
|
||
const rawTab = new URLSearchParams(window.location.search).get('tab');
|
||
activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
||
mounted = true;
|
||
try {
|
||
const [athleteRes, indexRes] = await Promise.all([
|
||
fetch(`${import.meta.env.BASE_URL}data/athlete.json`),
|
||
fetch(`${import.meta.env.BASE_URL}data/index.json`),
|
||
]);
|
||
if (!athleteRes.ok) throw new Error('athlete.json not found — run bincio extract first');
|
||
athlete = await athleteRes.json();
|
||
const index: BASIndex = await indexRes.json();
|
||
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
||
} catch (e: any) {
|
||
error = e.message;
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
});
|
||
|
||
async function onSaved() {
|
||
const res = await fetch(`${import.meta.env.BASE_URL}data/athlete.json?t=${Date.now()}`);
|
||
if (res.ok) athlete = await res.json();
|
||
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 TABS: { key: Tab; label: string }[] = [
|
||
{ key: 'power', label: 'Power Curve' },
|
||
{ key: 'records', label: 'Records' },
|
||
{ key: 'profile', label: 'Profile' },
|
||
];
|
||
</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}
|
||
|
||
<!-- 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 editUrl}
|
||
<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} />
|
||
</div>
|
||
{:else}
|
||
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data and re-run <code class="text-zinc-300">bincio extract</code>.</p>
|
||
{/if}
|
||
|
||
<!-- Records tab -->
|
||
{:else if activeTab === 'records'}
|
||
<RecordsView {athlete} {base} />
|
||
|
||
<!-- 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 && editUrl}
|
||
<AthleteDrawer
|
||
{editUrl}
|
||
on:close={() => drawerOpen = false}
|
||
on:saved={onSaved}
|
||
/>
|
||
{/if}
|