VAM: drop duration curve, show avg climbing VAM in Nerd Corner

Remove the per-duration VAM curve everywhere (metrics, summaries, detail
JSON, athlete.json, VamChart.svelte, AthleteView VAM tab). Keep only
climbing_vam_mh per activity. Add it to activity summaries so NerdCorner
can plot average climbing VAM per week/month year-over-year alongside
distance/elevation/time. Add --backfill-vam-summary flag to copy the
field from existing detail JSONs into index.json without re-extracting.
This commit is contained in:
Davide Scaini
2026-05-16 22:03:40 +02:00
parent 7cd8a6b030
commit 003b540481
8 changed files with 77 additions and 346 deletions
+27 -6
View File
@@ -5,7 +5,7 @@
export let activities: ActivitySummary[] = [];
type Metric = 'distance' | 'elevation' | 'time';
type Metric = 'distance' | 'elevation' | 'time' | 'vam';
type Granularity = 'week' | 'month';
let metric: Metric = 'distance';
@@ -15,11 +15,13 @@
distance: 'Distance (km)',
elevation: 'Elevation gain (m)',
time: 'Moving time (h)',
vam: 'Avg climbing VAM (m/h)',
};
const METRIC_FMT: Record<Metric, (v: number) => string> = {
distance: v => `${Math.round(v)} km`,
elevation: v => `${Math.round(v)} m`,
time: v => `${v.toFixed(1)} h`,
vam: v => `${Math.round(v)} m/h`,
};
// Cool→warm ramp for past years; current year is always blue-400
@@ -57,18 +59,34 @@
function buildData(acts: ActivitySummary[], m: Metric, g: Granularity) {
const curPeriod = g === 'week' ? weekOfYear(_now) : _now.getMonth() + 1;
const byYear = new Map<number, Map<number, number>>();
const byYearCnt = new Map<number, Map<number, number>>(); // for VAM averaging
for (const act of acts) {
if (!act.started_at) continue;
if (m === 'vam' && act.climbing_vam_mh == null) continue;
const d = new Date(act.started_at);
const yr = d.getFullYear();
const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1;
const val = m === 'distance' ? (act.distance_m ?? 0) / 1000
: m === 'elevation' ? (act.elevation_gain_m ?? 0)
: m === 'vam' ? (act.climbing_vam_mh ?? 0)
: (act.moving_time_s ?? 0) / 3600;
if (!byYear.has(yr)) byYear.set(yr, new Map());
const ym = byYear.get(yr)!;
ym.set(per, (ym.get(per) ?? 0) + val);
if (!byYear.has(yr)) byYear.set(yr, new Map());
if (!byYearCnt.has(yr)) byYearCnt.set(yr, new Map());
const ym = byYear.get(yr)!;
const ymc = byYearCnt.get(yr)!;
ym.set(per, (ym.get(per) ?? 0) + val);
ymc.set(per, (ymc.get(per) ?? 0) + 1);
}
// VAM: convert sums to averages
if (m === 'vam') {
for (const [yr, ym] of byYear) {
const ymc = byYearCnt.get(yr)!;
for (const [per, sum] of ym) {
ym.set(per, sum / (ymc.get(per) ?? 1));
}
}
}
const years = [...byYear.keys()].sort();
@@ -241,6 +259,7 @@
<button class="pill" class:active={metric === 'distance'} on:click={() => metric = 'distance'}>Distance</button>
<button class="pill" class:active={metric === 'elevation'} on:click={() => metric = 'elevation'}>Elevation</button>
<button class="pill" class:active={metric === 'time'} on:click={() => metric = 'time'}>Time</button>
<button class="pill" class:active={metric === 'vam'} on:click={() => metric = 'vam'}>Climbing VAM</button>
</div>
<div class="pill-group">
<button class="pill" class:active={granularity === 'week'} on:click={() => granularity = 'week'}>Weekly</button>
@@ -250,8 +269,10 @@
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
<p class="section-label">Cumulative</p>
<div bind:this={chartCumEl} class="w-full min-h-[320px]"></div>
{#if metric !== 'vam'}
<p class="section-label">Cumulative</p>
<div bind:this={chartCumEl} class="w-full min-h-[320px]"></div>
{/if}
{#if !rows.length}
<p class="text-zinc-500 text-sm mt-4">No activity data to display.</p>