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:
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user