added filtering to the stats page

This commit is contained in:
Davide Scaini
2026-03-29 11:01:08 +02:00
parent e71e8783ab
commit ee98704562
+148 -45
View File
@@ -1,39 +1,114 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types'; import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, sportIcon } from '../lib/format'; import { formatDistance, sportIcon, sportColor, sportLabel } from '../lib/format';
let activities: ActivitySummary[] = []; let all: ActivitySummary[] = [];
let sport: Sport | 'all' = 'all';
let loading = true; let loading = true;
onMount(async () => { onMount(async () => {
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`); const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
const index: BASIndex = await res.json(); const index: BASIndex = await res.json();
activities = index.activities.filter(a => a.privacy !== 'private' && a.distance_m); all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
loading = false; loading = false;
}); });
// ── Heatmap ─────────────────────────────────────────────────────────────── $: activities = sport === 'all' ? all : all.filter(a => a.sport === sport);
// Build a map: dateString → total distance (m)
$: byDate = (() => { // ── Heatmap data ─────────────────────────────────────────────────────────
const m = new Map<string, number>(); // byDateBySport: date → sport → total distance (m)
$: byDateBySport = (() => {
const m = new Map<string, Map<string, number>>();
for (const a of activities) { for (const a of activities) {
const d = a.started_at.slice(0, 10); // YYYY-MM-DD const d = a.started_at.slice(0, 10);
m.set(d, (m.get(d) ?? 0) + (a.distance_m ?? 0)); 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; return m;
})(); })();
// Current year and prior 3 years to show $: byDate = new Map(
[...byDateBySport.entries()].map(([d, sm]) => [
d,
[...sm.values()].reduce((s, v) => s + v, 0),
])
);
// Auto-scale: max daily km across all dates in filtered data
$: maxDailyKm = Math.max(...[...byDate.values()].map(v => v / 1000), 1);
// ── 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),
];
}
// Lerp from zinc-800 bg (#27272a = 39,39,42) toward target color
function applyIntensity(hex: string, intensity: number): string {
const [tr, tg, tb] = hexToRgb(hex);
return `rgb(${Math.round(39 + (tr - 39) * intensity)},${Math.round(39 + (tg - 39) * intensity)},${Math.round(42 + (tb - 42) * 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 m = new Map<string, string>();
for (const [date, sportMap] of byDateBySport) {
const total = byDate.get(date) ?? 0;
if (total === 0) { m.set(date, '#27272a'); continue; }
const km = total / 1000;
const intensity = Math.min(0.12 + (km / maxDailyKm) * 0.88, 1.0);
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));
}
return m;
})();
// Legend: 6 swatches from dark to full sport color (or neutral for 'all')
$: legendColor = sport !== 'all' ? sportColor(sport) : '#00c8ff';
$: legendSwatches = [0, 0.18, 0.38, 0.58, 0.78, 1.0].map(t =>
t === 0 ? '#27272a' : applyIntensity(legendColor, t)
);
// 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 ──────────────────────────────────────────────────────
const now = new Date(); const now = new Date();
const years = [now.getFullYear(), now.getFullYear()-1, now.getFullYear()-2, now.getFullYear()-3]; const years = [now.getFullYear(), now.getFullYear()-1, now.getFullYear()-2, now.getFullYear()-3];
function getWeeks(year: number): string[][] { function getWeeks(year: number): string[][] {
// Returns array of weeks, each week is array of 7 date strings (MonSun)
// Pad with empty strings at start/end
const jan1 = new Date(year, 0, 1); const jan1 = new Date(year, 0, 1);
const dec31 = new Date(year, 11, 31); const dec31 = new Date(year, 11, 31);
// Align to Monday
const start = new Date(jan1); const start = new Date(jan1);
start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7)); start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7));
const end = new Date(dec31); const end = new Date(dec31);
@@ -52,32 +127,6 @@
return weeks; return weeks;
} }
function cellColor(date: string): string {
if (!date) return 'transparent';
const km = (byDate.get(date) ?? 0) / 1000;
if (km === 0) return '#27272a'; // zinc-800
if (km < 20) return '#0e4c5a';
if (km < 50) return '#0a6e82';
if (km < 80) return '#0891b2'; // cyan-600
if (km < 120) return '#06b6d4'; // cyan-500
return '#00c8ff';
}
// ── 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);
const DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; const DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -92,8 +141,51 @@
return [{ month: m, col: i }]; return [{ month: m, col: i }];
}); });
} }
function cellTitle(date: string): string {
if (!date) return '';
const sportMap = byDateBySport.get(date);
if (!sportMap) return date;
const parts = [...sportMap.entries()]
.sort((a, b) => b[1] - a[1])
.map(([sp, dist]) => `${sportIcon(sp as Sport)} ${formatDistance(dist)}`);
return `${date}\n${parts.join(' ')}`;
}
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> </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} {#if loading}
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6" /> <div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6" />
{:else} {:else}
@@ -134,7 +226,6 @@
style="left: calc({col} * 13px)" style="left: calc({col} * 13px)"
>{month}</span> >{month}</span>
{/each} {/each}
<!-- spacer to set width -->
<div style="width:{weeks.length * 13}px" /> <div style="width:{weeks.length * 13}px" />
</div> </div>
@@ -154,8 +245,8 @@
{#each week as date} {#each week as date}
<div <div
class="w-[10px] h-[10px] rounded-[2px]" class="w-[10px] h-[10px] rounded-[2px]"
style="background:{cellColor(date)}" style="background:{cellColors.get(date) ?? '#27272a'}"
title={date ? `${date}: ${formatDistance(byDate.get(date) ?? 0)}` : ''} title={cellTitle(date)}
/> />
{/each} {/each}
</div> </div>
@@ -165,12 +256,24 @@
</div> </div>
<!-- Legend --> <!-- Legend -->
<div class="flex items-center gap-1 mt-2"> <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> <span class="text-xs text-zinc-500 mr-1">Less</span>
{#each ['#27272a','#0e4c5a','#0a6e82','#0891b2','#06b6d4','#00c8ff'] as c} {#each legendSwatches as c}
<div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}" /> <div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}" />
{/each} {/each}
<span class="text-xs text-zinc-500 ml-1">More</span> <span class="text-xs text-zinc-500 ml-1">More ({Math.round(maxDailyKm)} km max)</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)}" />
{sportIcon(sp)}
</span>
{/each}
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}