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.
This commit is contained in:
Davide Scaini
2026-05-16 22:25:30 +02:00
parent 003b540481
commit 08e8e54c36
2 changed files with 118 additions and 4 deletions
+1 -1
View File
@@ -210,7 +210,7 @@
{#if activeTab === 'power'} {#if activeTab === 'power'}
{#if athlete.power_curve.all_time} {#if athlete.power_curve.all_time}
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800"> <div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
<MmpChart {athlete} {activities} /> <MmpChart {athlete} {activities} {base} />
</div> </div>
{:else} {:else}
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data.</p> <p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data.</p>
+117 -3
View File
@@ -5,6 +5,7 @@
export let athlete: AthleteJson; export let athlete: AthleteJson;
export let activities: ActivitySummary[] = []; export let activities: ActivitySummary[] = [];
export let base: string = '/';
// ── Range selection ──────────────────────────────────────────────────────── // ── Range selection ────────────────────────────────────────────────────────
type RangeKey = 'all_time' | 'last_365d' | 'last_90d' | string; type RangeKey = 'all_time' | 'last_365d' | 'last_90d' | string;
@@ -35,6 +36,39 @@
return PALETTE[index % PALETTE.length]; 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<number, ActivitySummary> {
const map = new Map<number, ActivitySummary>();
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 ────────────────────────────────────────────────── // ── MMP curve computation ──────────────────────────────────────────────────
function mergeMmps(mmps: MmpCurve[]): MmpCurve { function mergeMmps(mmps: MmpCurve[]): MmpCurve {
@@ -74,10 +108,17 @@
$: selectedKeys = [...selectedRanges]; $: 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); const curve = mmpsForRange(key);
if (!curve) return []; 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)])); $: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)]));
@@ -131,7 +172,7 @@
fill: 'label', fill: 'label',
r: 3, r: 3,
tip: true, 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 ? [ ...(athlete.ftp_w ? [
Plot.ruleY([athlete.ftp_w], { Plot.ruleY([athlete.ftp_w], {
@@ -185,6 +226,25 @@
...Object.keys(PRESET_LABELS), ...Object.keys(PRESET_LABELS),
...seasons.map(s => s.name), ...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' });
}
</script> </script>
<style> <style>
@@ -215,3 +275,57 @@
{#if !plotData.length} {#if !plotData.length}
<p class="text-zinc-500 text-sm mt-4">No power data for the selected range.</p> <p class="text-zinc-500 text-sm mt-4">No power data for the selected range.</p>
{/if} {/if}
<!-- Records table -->
{#if tableRows.length}
<div class="mt-6">
<div class="flex items-center justify-between mb-3">
<h4 class="text-xs font-semibold text-zinc-400 uppercase tracking-wide">Power records</h4>
<div class="flex gap-1">
{#each allRangeKeys as key, i}
<button
on:click={() => tableRange = key}
class="px-2.5 py-1 rounded-full text-xs font-medium border transition-colors"
style={tableRange === key
? `background:${curveColor(key, i)}22; border-color:${curveColor(key, i)}; color:${curveColor(key, i)}`
: 'background:transparent; border-color:#3f3f46; color:#71717a'}
>{PRESET_LABELS[key] ?? key}</button>
{/each}
</div>
</div>
<div class="rounded-xl border border-zinc-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-zinc-800">
<tr class="text-left text-xs text-zinc-500">
<th class="px-4 py-2">Duration</th>
<th class="px-4 py-2 text-right">Power</th>
<th class="px-4 py-2">Activity</th>
<th class="px-4 py-2 hidden sm:table-cell text-right">Date</th>
</tr>
</thead>
<tbody>
{#each tableRows as row}
<tr class="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/30 transition-colors">
<td class="px-4 py-2.5 font-mono text-zinc-300 text-xs">{formatDuration(row.d)}</td>
<td class="px-4 py-2.5 text-right font-medium text-white">{row.w} W</td>
<td class="px-4 py-2.5">
{#if row.act}
<a href="{base}activity/{row.act.id}/"
class="text-blue-400 hover:text-blue-300 transition-colors truncate block max-w-[200px]"
title={row.act.title}>
{row.act.title}
</a>
{:else}
<span class="text-zinc-600 text-xs"></span>
{/if}
</td>
<td class="px-4 py-2.5 text-zinc-500 text-xs hidden sm:table-cell text-right">
{row.act ? fmtDate(row.act.started_at) : ''}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}