added filtering to the stats page
This commit is contained in:
@@ -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 (Mon–Sun)
|
|
||||||
// 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,36 +127,10 @@
|
|||||||
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'];
|
||||||
|
|
||||||
function monthLabels(weeks: string[][]): Array<{month:string;col:number}> {
|
function monthLabels(weeks: string[][]): Array<{ month: string; col: number }> {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return weeks.flatMap((week, i) => {
|
return weeks.flatMap((week, i) => {
|
||||||
const day = week.find(d => d);
|
const day = week.find(d => d);
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user