edit athlete data
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let editUrl: string; // PUBLIC_EDIT_URL base
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void; saved: void }>();
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let error: string | null = null;
|
||||
let status: string | null = null;
|
||||
|
||||
let maxHr: number | null = null;
|
||||
let ftpW: number | null = null;
|
||||
let hrZones: [number, number][] = [];
|
||||
let powerZones: [number, number][] = [];
|
||||
let seasons: { name: string; start: string; end: string }[] = [];
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
async function load() {
|
||||
loading = true; error = null;
|
||||
try {
|
||||
const res = await fetch(`${editUrl}/api/athlete`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const d = await res.json();
|
||||
maxHr = d.max_hr ?? null;
|
||||
ftpW = d.ftp_w ?? null;
|
||||
hrZones = d.hr_zones ? d.hr_zones.map((z: number[]) => [z[0], z[1]] as [number,number]) : [];
|
||||
powerZones = d.power_zones ? d.power_zones.map((z: number[]) => [z[0], z[1]] as [number,number]) : [];
|
||||
seasons = d.seasons ?? [];
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
load();
|
||||
|
||||
// ── Zone editing helpers ───────────────────────────────────────────────────
|
||||
function onHiChange(zones: [number, number][], i: number) {
|
||||
// Cascade: next row's lo = this row's hi
|
||||
if (i + 1 < zones.length) zones[i + 1][0] = zones[i][1];
|
||||
zones = [...zones]; // trigger reactivity
|
||||
return zones;
|
||||
}
|
||||
|
||||
function addZone(zones: [number, number][]): [number, number][] {
|
||||
const prevHi = zones.length ? zones[zones.length - 1][1] : 0;
|
||||
return [...zones, [prevHi, prevHi + 50]];
|
||||
}
|
||||
|
||||
function removeZone(zones: [number, number][], i: number): [number, number][] {
|
||||
const next = zones.filter((_, idx) => idx !== i);
|
||||
// Re-cascade lo values
|
||||
for (let j = 1; j < next.length; j++) next[j][0] = next[j - 1][1];
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Season helpers ─────────────────────────────────────────────────────────
|
||||
function addSeason() {
|
||||
const year = new Date().getFullYear();
|
||||
seasons = [...seasons, { name: `${year}`, start: `${year}-01-01`, end: `${year}-12-31` }];
|
||||
}
|
||||
function removeSeason(i: number) {
|
||||
seasons = seasons.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
async function save() {
|
||||
saving = true; status = null; error = null;
|
||||
try {
|
||||
const res = await fetch(`${editUrl}/api/athlete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
max_hr: maxHr,
|
||||
ftp_w: ftpW,
|
||||
hr_zones: hrZones,
|
||||
power_zones: powerZones,
|
||||
seasons,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
status = 'Saved.';
|
||||
dispatch('saved');
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer() { dispatch('close'); }
|
||||
|
||||
// Close on Escape
|
||||
function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') closeDrawer(); }
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
on:click={closeDrawer}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<!-- Drawer -->
|
||||
<aside class="fixed top-0 right-0 h-full w-full max-w-lg bg-zinc-950 border-l border-zinc-800 z-50 flex flex-col shadow-2xl overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||
<h2 class="text-base font-semibold text-white">Edit Athlete Profile</h2>
|
||||
<button on:click={closeDrawer} class="text-zinc-400 hover:text-white text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex-1 flex items-center justify-center text-zinc-500 text-sm">Loading…</div>
|
||||
{:else if error && !saving}
|
||||
<div class="flex-1 flex items-center justify-center text-red-400 text-sm px-6 text-center">{error}</div>
|
||||
{:else}
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-8">
|
||||
|
||||
<!-- Key numbers -->
|
||||
<section>
|
||||
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-3">Key numbers</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-500 mb-1 block">Max HR (bpm)</span>
|
||||
<input
|
||||
type="number" min="100" max="250"
|
||||
bind:value={maxHr}
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-500 mb-1 block">FTP (watts)</span>
|
||||
<input
|
||||
type="number" min="50" max="600"
|
||||
bind:value={ftpW}
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HR Zones -->
|
||||
<section>
|
||||
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-3">HR Zones (bpm)</h3>
|
||||
<div class="space-y-2">
|
||||
{#each hrZones as zone, i}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-500 w-5">Z{i+1}</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={zone[0]}
|
||||
disabled={i === 0}
|
||||
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 disabled:opacity-40"
|
||||
/>
|
||||
<span class="text-zinc-600 text-xs">–</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={zone[1]}
|
||||
on:change={() => { hrZones = onHiChange(hrZones, i); }}
|
||||
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span class="text-xs text-zinc-600">bpm</span>
|
||||
<button
|
||||
on:click={() => { hrZones = removeZone(hrZones, i); }}
|
||||
class="ml-auto text-zinc-600 hover:text-red-400 text-sm leading-none"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
on:click={() => { hrZones = addZone(hrZones); }}
|
||||
class="mt-2 text-xs text-zinc-500 hover:text-white border border-zinc-700 hover:border-zinc-500 rounded px-3 py-1 transition-colors"
|
||||
>+ Add zone</button>
|
||||
</section>
|
||||
|
||||
<!-- Power Zones -->
|
||||
<section>
|
||||
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-3">Power Zones (watts)</h3>
|
||||
<div class="space-y-2">
|
||||
{#each powerZones as zone, i}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-500 w-5">Z{i+1}</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={zone[0]}
|
||||
disabled={i === 0}
|
||||
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 disabled:opacity-40"
|
||||
/>
|
||||
<span class="text-zinc-600 text-xs">–</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={zone[1]}
|
||||
on:change={() => { powerZones = onHiChange(powerZones, i); }}
|
||||
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span class="text-xs text-zinc-600">W</span>
|
||||
<button
|
||||
on:click={() => { powerZones = removeZone(powerZones, i); }}
|
||||
class="ml-auto text-zinc-600 hover:text-red-400 text-sm leading-none"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
on:click={() => { powerZones = addZone(powerZones); }}
|
||||
class="mt-2 text-xs text-zinc-500 hover:text-white border border-zinc-700 hover:border-zinc-500 rounded px-3 py-1 transition-colors"
|
||||
>+ Add zone</button>
|
||||
</section>
|
||||
|
||||
<!-- Seasons -->
|
||||
<section>
|
||||
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-1">Seasons</h3>
|
||||
<p class="text-xs text-zinc-600 mb-3">Overlay multiple seasons on the power curve chart.</p>
|
||||
<div class="space-y-2">
|
||||
{#each seasons as season, i}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={season.name}
|
||||
placeholder="Name"
|
||||
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={season.start}
|
||||
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span class="text-zinc-600 text-xs">→</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={season.end}
|
||||
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
on:click={() => removeSeason(i)}
|
||||
class="text-zinc-600 hover:text-red-400 text-sm leading-none"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
on:click={addSeason}
|
||||
class="mt-2 text-xs text-zinc-500 hover:text-white border border-zinc-700 hover:border-zinc-500 rounded px-3 py-1 transition-colors"
|
||||
>+ Add season</button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center gap-3 px-5 py-4 border-t border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
on:click={save}
|
||||
disabled={saving}
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm font-medium rounded-md transition-colors"
|
||||
>{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button
|
||||
on:click={closeDrawer}
|
||||
class="px-4 py-2 border border-zinc-700 hover:border-zinc-500 text-zinc-300 text-sm rounded-md transition-colors"
|
||||
>Cancel</button>
|
||||
{#if status}
|
||||
<span class="text-green-400 text-sm">{status}</span>
|
||||
{/if}
|
||||
{#if error}
|
||||
<span class="text-red-400 text-sm truncate">{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -2,11 +2,15 @@
|
||||
import { onMount } from 'svelte';
|
||||
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
|
||||
import MmpChart from './MmpChart.svelte';
|
||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||
|
||||
let athlete: AthleteJson | null = null;
|
||||
let activities: ActivitySummary[] = [];
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
let drawerOpen = false;
|
||||
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -19,6 +23,7 @@
|
||||
const index: BASIndex = await indexRes.json();
|
||||
// Only activities with power data contribute to the curve
|
||||
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
||||
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
@@ -26,6 +31,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function onSaved() {
|
||||
// Reload athlete.json after edits are saved
|
||||
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`;
|
||||
@@ -42,6 +54,16 @@
|
||||
<p class="text-red-400 text-sm">{error}</p>
|
||||
{:else if athlete}
|
||||
|
||||
<!-- Edit button (only when edit server is configured) -->
|
||||
{#if editUrl}
|
||||
<div class="flex justify-end mb-6">
|
||||
<button
|
||||
on:click={() => drawerOpen = true}
|
||||
class="px-4 py-2 text-sm border border-zinc-700 hover:border-zinc-500 text-zinc-300 hover:text-white rounded-md transition-colors"
|
||||
>Edit profile</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Power curve section -->
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Power Curve</h2>
|
||||
@@ -109,3 +131,11 @@
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
|
||||
{#if drawerOpen && editUrl}
|
||||
<AthleteDrawer
|
||||
{editUrl}
|
||||
on:close={() => drawerOpen = false}
|
||||
on:saved={onSaved}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user