Files
bincio-activity/site/src/components/StatsView.svelte
T
2026-04-20 15:07:52 +02:00

455 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndex } from '../lib/dataloader';
/** Explicit index URL — use for per-user stats pages in multi-user mode. */
export let indexUrl: string = '';
const PAGE_YEARS = 4;
let all: ActivitySummary[] = [];
let sport: Sport | 'all' = 'all';
let page = 0;
let loading = true;
let error = '';
let theme = 'dark';
let mounted = false;
$: totalPages = Math.ceil(allYears.length / PAGE_YEARS);
$: years = allYears.slice(page * PAGE_YEARS, (page + 1) * PAGE_YEARS);
$: if (mounted) {
const params = new URLSearchParams(window.location.search);
if (sport === 'all') params.delete('sport'); else params.set('sport', sport);
if (page === 0) params.delete('page'); else params.set('page', String(page));
const qs = params.toString();
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
}
onMount(async () => {
const params = new URLSearchParams(window.location.search);
sport = (params.get('sport') as Sport | 'all') ?? 'all';
page = parseInt(params.get('page') ?? '0', 10) || 0;
mounted = true;
try {
const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined);
all = index.activities.filter(a => !isUnlisted(a.privacy) && a.distance_m);
} catch (e: any) {
error = e.message;
}
loading = false;
theme = document.documentElement.getAttribute('data-theme') ?? 'dark';
const obs = new MutationObserver(() => {
theme = document.documentElement.getAttribute('data-theme') ?? 'dark';
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
});
$: activities = sport === 'all' ? all : all.filter(a => a.sport === sport);
// ── Tooltip ───────────────────────────────────────────────────────────────
$: activitiesByDate = (() => {
const m = new Map<string, ActivitySummary[]>();
for (const a of activities) {
const d = a.started_at.slice(0, 10);
if (!m.has(d)) m.set(d, []);
m.get(d)!.push(a);
}
return m;
})();
let hoveredDate: string | null = null;
let pinnedDate: string | null = null;
let tooltipEl: HTMLElement | null = null;
let tooltipPos = { x: 0, y: 0 };
let hideTimer: ReturnType<typeof setTimeout> | null = null;
$: tooltipDate = pinnedDate ?? hoveredDate;
$: tooltipActivities = tooltipDate ? (activitiesByDate.get(tooltipDate) ?? []) : [];
function updatePos(e: MouseEvent) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const tw = 280; // matches w-[280px]
const th = 260; // approximate tooltip height
const gap = 14;
let x = e.clientX + gap;
if (x + tw > vw) x = e.clientX - gap - tw;
x = Math.max(4, Math.min(x, vw - tw - 4));
const y = Math.max(4, Math.min(e.clientY - 8, vh - th - 4));
tooltipPos = { x, y };
}
function onCellEnter(date: string, e: MouseEvent) {
if (!date || !activitiesByDate.has(date)) return;
if (pinnedDate) return;
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
hoveredDate = date;
updatePos(e);
}
function onCellLeave() {
if (pinnedDate) return;
hideTimer = setTimeout(() => { hoveredDate = null; }, 120);
}
function onCellClick(date: string, e: MouseEvent) {
if (!date || !activitiesByDate.has(date)) return;
e.stopPropagation();
if (pinnedDate === date) {
pinnedDate = null;
} else {
pinnedDate = date;
updatePos(e);
}
}
function onTooltipEnter() {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
}
function onTooltipLeave() {
if (pinnedDate) return;
hoveredDate = null;
}
function onWindowClick(e: MouseEvent) {
if (!pinnedDate) return;
if (tooltipEl && tooltipEl.contains(e.target as Node)) return;
pinnedDate = null;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') pinnedDate = null;
}
// ── Heatmap data ─────────────────────────────────────────────────────────
// byDateBySport: date → sport → total distance (m)
$: byDateBySport = (() => {
const m = new Map<string, Map<string, number>>();
for (const a of activities) {
const d = a.started_at.slice(0, 10);
if (!m.has(d)) m.set(d, new Map());
const sm = m.get(d)!;
sm.set(a.sport, (sm.get(a.sport) ?? 0) + (a.distance_m ?? 0));
}
return m;
})();
$: byDate = new Map(
[...byDateBySport.entries()].map(([d, sm]) => [
d,
[...sm.values()].reduce((s, v) => s + v, 0),
])
);
// Sorted daily distances for percentile-based intensity scaling
$: sortedDaily = [...byDate.values()].sort((a, b) => a - b);
$: maxDailyKm = (sortedDaily[sortedDaily.length - 1] ?? 0) / 1000 || 1;
function pctRank(value: number, sorted: number[]): number {
if (!sorted.length) return 0;
let lo = 0, hi = sorted.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] <= value) lo = mid + 1; else hi = mid; }
return lo / sorted.length;
}
// ── Totals ────────────────────────────────────────────────────────────────
$: totalsByYear = (() => {
const m = new Map<number, { dist: number; count: number }>();
for (const a of activities) {
const y = new Date(a.started_at).getFullYear();
const cur = m.get(y) ?? { dist: 0, count: 0 };
cur.dist += a.distance_m ?? 0;
cur.count += 1;
m.set(y, cur);
}
return m;
})();
$: allYears = [...totalsByYear.keys()].sort((a, b) => b - a);
// ── Color helpers ─────────────────────────────────────────────────────────
function hexToRgb(hex: string): [number, number, number] {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
}
// Base cell color: zinc-800 dark (#27272a=39,39,42) or zinc-200 light (#e4e4e7=228,228,231)
$: emptyColor = theme === 'light' ? '#e4e4e7' : '#27272a';
$: baseRgb = theme === 'light'
? [228, 228, 231] as [number, number, number]
: [39, 39, 42] as [number, number, number];
// Lerp from base bg color toward target sport color
function applyIntensity(hex: string, intensity: number, base: [number, number, number]): string {
const [tr, tg, tb] = hexToRgb(hex);
const [br, bg, bb] = base;
return `rgb(${Math.round(br + (tr - br) * intensity)},${Math.round(bg + (tg - bg) * intensity)},${Math.round(bb + (tb - bb) * intensity)})`;
}
// Precompute date→color as a reactive Map so Svelte tracks it directly in
// the template. (Calling a plain function with a static string arg won't
// re-trigger when byDate/maxDailyKm change — the Map reference does.)
$: cellColors = (() => {
const base = baseRgb;
const empty = emptyColor;
const m = new Map<string, string>();
for (const [date, sportMap] of byDateBySport) {
const total = byDate.get(date) ?? 0;
if (total === 0) { m.set(date, empty); continue; }
const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88;
let tr = 0, tg = 0, tb = 0;
for (const [sp, dist] of sportMap) {
const w = dist / total;
const [cr, cg, cb] = hexToRgb(sportColor(sp as Sport));
tr += cr * w; tg += cg * w; tb += cb * w;
}
const blended = `#${Math.round(tr).toString(16).padStart(2,'0')}${Math.round(tg).toString(16).padStart(2,'0')}${Math.round(tb).toString(16).padStart(2,'0')}`;
m.set(date, applyIntensity(blended, intensity, base));
}
return m;
})();
// Legend: 6 swatches from base bg to full sport color (or neutral for 'all')
$: legendColor = sport !== 'all' ? sportColor(sport) : (theme === 'light' ? '#0284c7' : '#00c8ff');
$: legendSwatches = [0, 0.18, 0.38, 0.58, 0.78, 1.0].map(t =>
t === 0 ? emptyColor : applyIntensity(legendColor, t, baseRgb)
);
// Sport chips present in filtered data (for 'all' color key)
$: sportsInData = sport === 'all'
? ([...new Set(activities.map(a => a.sport))] as Sport[]).sort()
: ([] as Sport[]);
// ── Calendar helpers ──────────────────────────────────────────────────────
function localISO(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function getWeeks(year: number): string[][] {
const jan1 = new Date(year, 0, 1);
const dec31 = new Date(year, 11, 31);
const start = new Date(jan1);
start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7));
const end = new Date(dec31);
end.setDate(dec31.getDate() + (6 - (dec31.getDay() + 6) % 7));
const weeks: string[][] = [];
let cur = new Date(start);
while (cur <= end) {
const week: string[] = [];
for (let d = 0; d < 7; d++) {
week.push(cur.getFullYear() === year ? localISO(cur) : '');
cur.setDate(cur.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
const DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const sports: Array<{ value: Sport | 'all'; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'cycling', label: '🚴 Cycling' },
{ value: 'running', label: '🏃 Running' },
{ value: 'hiking', label: '🥾 Hiking' },
{ value: 'walking', label: '🚶 Walking' },
{ value: 'swimming', label: '🏊 Swimming' },
{ value: 'skiing', label: '⛷️ Skiing' },
{ value: 'other', label: '⚡ Other' },
];
</script>
<!-- Filter bar -->
<div class="flex gap-2 mb-6 flex-wrap">
{#each sports as s}
<button
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
class:border-zinc-700={sport !== s.value}
class:text-zinc-400={sport !== s.value}
class:border-[--accent]={sport === s.value}
class:text-white={sport === s.value}
style={sport === s.value ? 'background:var(--accent-dim)' : ''}
on:click={() => sport = s.value}
>
{s.label}
</button>
{/each}
{#if all.length > 0}
<span class="ml-auto text-sm text-zinc-500 self-center">
{activities.length} {activities.length === 1 ? 'activity' : 'activities'}
</span>
{/if}
</div>
{#if loading}
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6"></div>
{:else if error}
<p class="text-red-400 text-sm mt-4">{error}</p>
{:else}
<!-- Pagination controls -->
{#if totalPages > 1}
<div class="flex items-center justify-between mb-6">
<button
class="px-3 py-1.5 rounded-lg border border-zinc-700 text-sm text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page === 0}
on:click={() => page -= 1}
>← Newer</button>
<span class="text-sm text-zinc-500">{years[years.length - 1]} {years[0]}</span>
<button
class="px-3 py-1.5 rounded-lg border border-zinc-700 text-sm text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page >= totalPages - 1}
on:click={() => page += 1}
>Older →</button>
</div>
{/if}
<!-- Year totals -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
{#each years as year}
{@const t = totalsByYear.get(year)}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<p class="text-xs text-zinc-500 mb-1">{year}</p>
<p class="text-2xl font-bold text-white">{formatDistance(t?.dist ?? 0)}</p>
<p class="text-sm text-zinc-400">{t?.count ?? 0} activities</p>
</div>
{/each}
</div>
<!-- Heatmaps per year -->
{#each years as year}
{@const weeks = getWeeks(year)}
{@const yt = totalsByYear.get(year)}
{#if yt}
<div class="mb-8" role="presentation">
<div class="flex items-baseline gap-3 mb-2">
<h2 class="text-lg font-semibold text-white">{year}</h2>
<span class="text-sm text-zinc-400">
{formatDistance(yt.dist)} · {yt.count} activities
</span>
</div>
<div class="overflow-x-auto">
<div class="inline-flex gap-[3px]">
<!-- Day-of-week labels: blank slot at top to align with month row -->
<div class="flex flex-col gap-[3px] mr-1">
<div class="h-4"></div>
{#each DOW as d, i}
<span class="text-[9px] text-zinc-600 h-[10px] leading-[10px] w-3 text-right">
{i % 2 === 1 ? d : ''}
</span>
{/each}
</div>
<!-- Week columns: month label at top, day cells below -->
{#each weeks as week, wi}
{@const firstDay = week.find(d => d)}
{@const prevFirstDay = wi > 0 ? weeks[wi - 1].find(d => d) : null}
{@const showMonth = firstDay && (!prevFirstDay || prevFirstDay.slice(5, 7) !== firstDay.slice(5, 7))}
<div class="flex flex-col gap-[3px]">
<div class="h-4 relative">
{#if showMonth}
<span class="text-[10px] text-zinc-500 absolute left-0 top-0 whitespace-nowrap">
{MONTHS[parseInt(firstDay.slice(5, 7)) - 1]}
</span>
{/if}
</div>
{#each week as date}
<div
role="button"
tabindex="0"
class="w-[10px] h-[10px] rounded-[2px] {date && activitiesByDate.has(date) ? 'cursor-pointer' : ''} {date && date === pinnedDate ? 'ring-1 ring-white ring-offset-[1px]' : ''}"
style="background:{cellColors.get(date) ?? emptyColor}; --tw-ring-offset-color: var(--bg-base)"
on:mouseenter={e => onCellEnter(date, e)}
on:mouseleave={onCellLeave}
on:click={e => onCellClick(date, e)}
on:keydown={e => e.key === 'Enter' && onCellClick(date, e)}
></div>
{/each}
</div>
{/each}
</div>
</div>
<!-- Legend -->
<div class="flex items-center gap-3 mt-2 flex-wrap">
<div class="flex items-center gap-1">
<span class="text-xs text-zinc-500 mr-1">Less</span>
{#each legendSwatches as c}
<div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}"></div>
{/each}
<span class="text-xs text-zinc-500 ml-1">More (percentile · max {Math.round(maxDailyKm)} km)</span>
</div>
{#if sportsInData.length > 1}
<div class="flex items-center gap-2 ml-2">
{#each sportsInData as sp}
<span class="text-xs flex items-center gap-1" style="color:{sportColor(sp)}">
<span class="w-[10px] h-[10px] rounded-[2px] inline-block" style="background:{sportColor(sp)}"></span>
{sportIcon(sp)}
</span>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/each}
{/if}
<svelte:window on:click={onWindowClick} on:keydown={onKeydown} />
<!-- Day tooltip -->
{#if tooltipDate && tooltipActivities.length > 0}
<div
bind:this={tooltipEl}
role="tooltip"
class="fixed z-50 bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-3 w-[280px]"
style="left:{tooltipPos.x}px; top:{tooltipPos.y}px"
on:mouseenter={onTooltipEnter}
on:mouseleave={onTooltipLeave}
>
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-zinc-400">
{new Date(tooltipDate + 'T12:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
{#if pinnedDate}
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors text-sm leading-none ml-2"
on:click|stopPropagation={() => pinnedDate = null}
aria-label="Close"
></button>
{/if}
</div>
<div class="flex flex-col gap-1">
{#each tooltipActivities as a}
<a
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
class="flex flex-col gap-0.5 rounded-lg px-2 py-1.5 hover:bg-zinc-800 transition-colors"
>
<span class="text-sm font-medium text-white truncate">
{sportIcon(a.sport)} {a.title}
</span>
<span class="text-xs text-zinc-400">
{formatDistance(a.distance_m)}
{#if a.moving_time_s ?? a.duration_s}
· {formatDuration(a.moving_time_s ?? a.duration_s)}
{/if}
<span class="ml-1" style="color:{sportColor(a.sport)}">{sportLabel(a.sport, a.sub_sport)}</span>
</span>
</a>
{/each}
</div>
</div>
{/if}