Files
bincio-activity/site/src/components/AthleteView.svelte
T
2026-04-01 19:00:28 +02:00

174 lines
6.1 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';
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}