Add Nerd Corner tab with year-over-year cumulative progress chart
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
import RecordsView from './RecordsView.svelte';
|
import RecordsView from './RecordsView.svelte';
|
||||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||||
import Explore from './Explore.svelte';
|
import Explore from './Explore.svelte';
|
||||||
|
import NerdCorner from './NerdCorner.svelte';
|
||||||
import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format';
|
import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format';
|
||||||
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
let drawerOpen = false;
|
let drawerOpen = false;
|
||||||
|
|
||||||
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore';
|
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
|
||||||
let activeTab: Tab = 'power';
|
let activeTab: Tab = 'power';
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
let isOwner = false;
|
let isOwner = false;
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
isOwner = (window as any).__bincioMe === handle;
|
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 rawTab = new URLSearchParams(window.location.search).get('tab');
|
||||||
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
||||||
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
|
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
|
||||||
@@ -149,7 +150,8 @@
|
|||||||
{ key: 'records', label: 'Records' },
|
{ key: 'records', label: 'Records' },
|
||||||
{ key: 'segments', label: 'Segments' },
|
{ key: 'segments', label: 'Segments' },
|
||||||
{ key: 'profile', label: 'Profile' },
|
{ key: 'profile', label: 'Profile' },
|
||||||
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
||||||
|
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
|
||||||
];
|
];
|
||||||
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
||||||
</script>
|
</script>
|
||||||
@@ -331,6 +333,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Explore tab -->
|
||||||
{:else if activeTab === 'explore'}
|
{:else if activeTab === 'explore'}
|
||||||
<div style="height: calc(100vh - 200px); margin: 0 -1rem -1.5rem;">
|
<div style="height: calc(100vh - 200px); margin: 0 -1rem -1.5rem;">
|
||||||
|
|||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user