Add Nerd Corner tab with year-over-year cumulative progress chart

This commit is contained in:
Davide Scaini
2026-05-14 16:37:01 +02:00
parent 487ce42361
commit 8804bdec37
2 changed files with 210 additions and 3 deletions
+11 -2
View File
@@ -5,6 +5,7 @@
import RecordsView from './RecordsView.svelte';
import AthleteDrawer from './AthleteDrawer.svelte';
import Explore from './Explore.svelte';
import NerdCorner from './NerdCorner.svelte';
import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format';
import { loadIndex, loadAthlete } from '../lib/dataloader';
@@ -22,7 +23,7 @@
let error: string | null = null;
let drawerOpen = false;
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore';
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
let activeTab: Tab = 'power';
let mounted = false;
let isOwner = false;
@@ -83,7 +84,7 @@
onMount(async () => {
isOwner = (window as any).__bincioMe === handle;
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore'];
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd'];
const rawTab = new URLSearchParams(window.location.search).get('tab');
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
@@ -150,6 +151,7 @@
{ key: 'segments', label: 'Segments' },
{ key: 'profile', label: 'Profile' },
{ key: 'explore', label: 'Explore', ownerOnly: true },
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
];
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
</script>
@@ -331,6 +333,13 @@
</div>
{/if}
<!-- Nerd Corner tab -->
{:else if activeTab === 'nerd'}
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-4">Year over year</h3>
<NerdCorner {activities} />
</div>
<!-- Explore tab -->
{:else if activeTab === 'explore'}
<div style="height: calc(100vh - 200px); margin: 0 -1rem -1.5rem;">
+198
View File
@@ -0,0 +1,198 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as Plot from '@observablehq/plot';
import type { ActivitySummary } from '../lib/types';
export let activities: ActivitySummary[] = [];
type Metric = 'distance' | 'elevation' | 'time';
type Granularity = 'week' | 'month';
let metric: Metric = 'distance';
let granularity: Granularity = 'week';
const METRIC_LABEL: Record<Metric, string> = {
distance: 'Distance (km)',
elevation: 'Elevation gain (m)',
time: 'Moving time (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`,
};
// Colours for past years — current year is always blue-400
const PALETTE = [
'#f97316', '#34d399', '#a78bfa', '#f43f5e',
'#facc15', '#22d3ee', '#fb923c', '#4ade80',
];
function dayOfYear(d: Date): number {
return Math.floor((d.getTime() - new Date(d.getFullYear(), 0, 0).getTime()) / 86400000);
}
function weekOfYear(d: Date): number {
return Math.min(52, Math.ceil(dayOfYear(d) / 7));
}
const _now = new Date();
const _currentYear = _now.getFullYear();
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>>();
for (const act of acts) {
if (!act.started_at) 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)
: (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);
}
const years = [...byYear.keys()].sort();
const maxPer = g === 'week' ? 52 : 12;
const rows: { year: string; period: number; value: number }[] = [];
for (const yr of years) {
const pm = byYear.get(yr)!;
const limit = yr === _currentYear ? curPeriod : maxPer;
let cum = 0;
for (let p = 1; p <= limit; p++) {
cum += pm.get(p) ?? 0;
rows.push({ year: String(yr), period: p, value: cum });
}
}
const colorDomain = years.map(String);
const colorRange = years.map((y, i) =>
y === _currentYear ? '#60a5fa' : PALETTE[i % PALETTE.length]);
return { rows, colorDomain, colorRange };
}
$: ({ rows, colorDomain, colorRange } = buildData(activities, metric, granularity));
let chartEl: HTMLElement;
function renderChart(
rows: { year: string; period: number; value: number }[],
colorDomain: string[],
colorRange: string[],
m: Metric,
g: Granularity,
) {
if (!chartEl) return;
chartEl.innerHTML = '';
if (!rows.length) return;
const maxPer = g === 'week' ? 52 : 12;
const xLabel = g === 'week' ? 'Week' : 'Month';
const axColor = document.documentElement.getAttribute('data-theme') === 'light' ? '#52525b' : '#a1a1aa';
const fmt = METRIC_FMT[m];
const curYear = String(_currentYear);
const pastRows = rows.filter(r => r.year !== curYear);
const curRows = rows.filter(r => r.year === curYear);
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const chart = Plot.plot({
width: chartEl.clientWidth || 700,
height: 320,
marginLeft: 60,
marginBottom: 40,
style: { background: 'transparent', color: axColor },
x: {
label: xLabel,
domain: [1, maxPer],
grid: true,
tickFormat: g === 'month'
? (d: number) => MONTH_LABELS[d - 1]
: (d: number) => String(d),
},
y: { label: METRIC_LABEL[m], grid: true, zero: true },
color: { domain: colorDomain, range: colorRange, legend: true },
marks: [
...(pastRows.length ? [
Plot.line(pastRows, {
x: 'period', y: 'value', stroke: 'year',
strokeWidth: 1.5, curve: 'monotone-x',
}),
Plot.dot(pastRows, {
x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0,
tip: true,
title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`,
}),
] : []),
...(curRows.length ? [
Plot.line(curRows, {
x: 'period', y: 'value', stroke: 'year',
strokeWidth: 2.5, curve: 'monotone-x',
}),
Plot.dot(curRows, {
x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0,
tip: true,
title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`,
}),
] : []),
],
});
chartEl.appendChild(chart);
}
$: renderChart(rows, colorDomain, colorRange, metric, granularity);
// Keep current values for resize / theme callbacks
let _r = rows, _cd = colorDomain, _cr = colorRange, _m = metric, _g = granularity;
$: _r = rows; $: _cd = colorDomain; $: _cr = colorRange; $: _m = metric; $: _g = granularity;
onMount(() => {
const ro = new ResizeObserver(() => renderChart(_r, _cd, _cr, _m, _g));
ro.observe(chartEl);
const mo = new MutationObserver(() => renderChart(_r, _cd, _cr, _m, _g));
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => { ro.disconnect(); mo.disconnect(); };
});
</script>
<style>
:global(.plot-tip text) { fill: #18181b !important; }
.controls { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1.25rem; align-items: center; }
.pill-group { display: flex; gap: 0.375rem; }
.pill {
padding: 0.2rem 0.65rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 500;
border: 1px solid #3f3f46;
color: #71717a;
background: transparent;
cursor: pointer;
transition: all 0.12s;
}
.pill.active { background: #1d4ed822; border-color: #60a5fa; color: #60a5fa; }
.pill:hover:not(.active) { border-color: #a1a1aa; color: #d4d4d8; }
</style>
<div class="controls">
<div class="pill-group">
<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>
</div>
<div class="pill-group">
<button class="pill" class:active={granularity === 'week'} on:click={() => granularity = 'week'}>Weekly</button>
<button class="pill" class:active={granularity === 'month'} on:click={() => granularity = 'month'}>Monthly</button>
</div>
</div>
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
{#if !rows.length}
<p class="text-zinc-500 text-sm mt-4">No activity data to display.</p>
{/if}