From 08e8e54c362bb1b668993047753099be18fd1777 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sat, 16 May 2026 22:25:30 +0200 Subject: [PATCH] Power curve: show record holder in tooltip and add records table 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. --- site/src/components/AthleteView.svelte | 2 +- site/src/components/MmpChart.svelte | 120 ++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index fbca99c..0735307 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -210,7 +210,7 @@ {#if activeTab === 'power'} {#if athlete.power_curve.all_time}
- +
{:else}

No power data found. Make sure your activities include power meter data.

diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte index cbdb4bf..6b34def 100644 --- a/site/src/components/MmpChart.svelte +++ b/site/src/components/MmpChart.svelte @@ -5,6 +5,7 @@ export let athlete: AthleteJson; export let activities: ActivitySummary[] = []; + export let base: string = '/'; // ── Range selection ──────────────────────────────────────────────────────── type RangeKey = 'all_time' | 'last_365d' | 'last_90d' | string; @@ -35,6 +36,39 @@ return PALETTE[index % PALETTE.length]; } + // ── Record-holder lookup ─────────────────────────────────────────────────── + + const _now = new Date(); + function _cutoff(days: number): string { + return new Date(_now.getTime() - days * 86400000).toISOString(); + } + const cutoff365 = _cutoff(365); + const cutoff90 = _cutoff(90); + + function activitiesInRange(key: RangeKey): ActivitySummary[] { + if (key === 'all_time') return activities; + if (key === 'last_365d') return activities.filter(a => a.started_at >= cutoff365); + if (key === 'last_90d') return activities.filter(a => a.started_at >= cutoff90); + const season = seasons.find(s => s.name === key); + if (!season) return []; + return activities.filter(a => a.started_at >= season.start && a.started_at <= season.end + 'T23:59:59'); + } + + // For each (rangeKey, duration_s) → the activity that holds the record. + // Precomputed as a Map to avoid re-scanning on every render. + function buildRecordMap(key: RangeKey): Map { + const map = new Map(); + for (const a of activitiesInRange(key)) { + if (!a.mmp) continue; + for (const [d, w] of a.mmp) { + const cur = map.get(d); + const curW = cur ? (cur.mmp!.find(([dd]) => dd === d)?.[1] ?? 0) : -1; + if (w > curW) map.set(d, a); + } + } + return map; + } + // ── MMP curve computation ────────────────────────────────────────────────── function mergeMmps(mmps: MmpCurve[]): MmpCurve { @@ -74,10 +108,17 @@ $: selectedKeys = [...selectedRanges]; - $: plotData = selectedKeys.flatMap((key, i) => { + // Record maps per selected range — recomputed when activities or selected keys change. + $: recordMaps = Object.fromEntries(selectedKeys.map(k => [k, buildRecordMap(k)])); + + $: plotData = selectedKeys.flatMap((key) => { const curve = mmpsForRange(key); if (!curve) return []; - return curve.map(([d, w]) => ({ d, w, label: key })); + const rmap = recordMaps[key] ?? new Map(); + return curve.map(([d, w]) => { + const act = rmap.get(d); + return { d, w, label: key, actTitle: act?.title ?? null, actId: act?.id ?? null }; + }); }); $: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)])); @@ -131,7 +172,7 @@ fill: 'label', r: 3, tip: true, - title: (d: any) => `${labelFn(d.label)}\n${formatDuration(d.d)}: ${d.w} W`, + title: (d: any) => `${labelFn(d.label)}\n${formatDuration(d.d)}: ${d.w} W${d.actTitle ? '\n' + d.actTitle : ''}`, }), ...(athlete.ftp_w ? [ Plot.ruleY([athlete.ftp_w], { @@ -185,6 +226,25 @@ ...Object.keys(PRESET_LABELS), ...seasons.map(s => s.name), ]; + + // ── Records table ────────────────────────────────────────────────────────── + + let tableRange: RangeKey = 'all_time'; + + $: tableRecordMap = buildRecordMap(tableRange); + + $: tableRows = (() => { + const curve = mmpsForRange(tableRange); + if (!curve) return []; + return curve.map(([d, w]) => { + const act = tableRecordMap.get(d); + return { d, w, act }; + }); + })(); + + function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + }