add overing in stats page
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
export let activity: ActivitySummary;
|
export let activity: ActivitySummary;
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
|
|
||||||
|
const editUrl = import.meta.env.PUBLIC_EDIT_URL;
|
||||||
|
|
||||||
let detail: ActivityDetail | null = null;
|
let detail: ActivityDetail | null = null;
|
||||||
let error = '';
|
let error = '';
|
||||||
// Linked hover index shared between map and charts
|
// Linked hover index shared between map and charts
|
||||||
@@ -27,17 +29,18 @@
|
|||||||
$: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null;
|
$: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null;
|
||||||
$: color = sportColor(activity.sport);
|
$: color = sportColor(activity.sport);
|
||||||
|
|
||||||
const stat = (label: string, value: string) => ({ label, value });
|
const stat = (label: string, value: string, key?: string) => ({ label, value, key });
|
||||||
|
$: hiddenStats = new Set<string>((detail?.custom as any)?.hide_stats ?? []);
|
||||||
$: stats = [
|
$: stats = [
|
||||||
stat('Distance', formatDistance(activity.distance_m)),
|
stat('Distance', formatDistance(activity.distance_m)),
|
||||||
stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)),
|
stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)),
|
||||||
stat('Elevation ↑', formatElevation(activity.elevation_gain_m)),
|
stat('Elevation ↑', formatElevation(activity.elevation_gain_m), 'elevation'),
|
||||||
stat('Avg speed', formatSpeed(activity.avg_speed_kmh)),
|
stat('Avg speed', formatSpeed(activity.avg_speed_kmh), 'speed'),
|
||||||
stat('Max speed', formatSpeed(activity.max_speed_kmh)),
|
stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'),
|
||||||
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—'),
|
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'),
|
||||||
stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—'),
|
stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—', 'heart_rate'),
|
||||||
stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—'),
|
stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'),
|
||||||
];
|
].filter(s => !s.key || !hiddenStats.has(s.key));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -57,9 +60,21 @@
|
|||||||
{formatDate(activity.started_at)} · {formatTime(activity.started_at)}
|
{formatDate(activity.started_at)} · {formatTime(activity.started_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-white">{activity.title}</h1>
|
<h1 class="text-2xl font-bold text-white">{activity.title}</h1>
|
||||||
|
{#if editUrl}
|
||||||
|
<a
|
||||||
|
href={`${editUrl}/edit/${activity.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if detail?.description}
|
{#if detail?.description}
|
||||||
<p class="text-zinc-400 mt-1 text-sm">{detail.description}</p>
|
<p class="text-zinc-400 mt-1 text-sm whitespace-pre-line">{detail.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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, sportColor, sportLabel } from '../lib/format';
|
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||||
|
|
||||||
let all: ActivitySummary[] = [];
|
let all: ActivitySummary[] = [];
|
||||||
let sport: Sport | 'all' = 'all';
|
let sport: Sport | 'all' = 'all';
|
||||||
@@ -16,6 +16,46 @@
|
|||||||
|
|
||||||
$: activities = sport === 'all' ? all : all.filter(a => a.sport === sport);
|
$: 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 tooltipPos = { x: 0, y: 0 };
|
||||||
|
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
$: tooltipActivities = hoveredDate ? (activitiesByDate.get(hoveredDate) ?? []) : [];
|
||||||
|
|
||||||
|
function onCellEnter(date: string, e: MouseEvent) {
|
||||||
|
if (!date || !activitiesByDate.has(date)) return;
|
||||||
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||||
|
hoveredDate = date;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
tooltipPos = {
|
||||||
|
x: e.clientX > vw - 310 ? e.clientX - 305 : e.clientX + 14,
|
||||||
|
y: Math.min(e.clientY - 8, vh - 260),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellLeave() {
|
||||||
|
hideTimer = setTimeout(() => { hoveredDate = null; }, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTooltipEnter() {
|
||||||
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTooltipLeave() {
|
||||||
|
hoveredDate = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Heatmap data ─────────────────────────────────────────────────────────
|
// ── Heatmap data ─────────────────────────────────────────────────────────
|
||||||
// byDateBySport: date → sport → total distance (m)
|
// byDateBySport: date → sport → total distance (m)
|
||||||
$: byDateBySport = (() => {
|
$: byDateBySport = (() => {
|
||||||
@@ -133,16 +173,6 @@
|
|||||||
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 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 }> = [
|
const sports: Array<{ value: Sport | 'all'; label: string }> = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'cycling', label: '🚴 Cycling' },
|
{ value: 'cycling', label: '🚴 Cycling' },
|
||||||
@@ -198,7 +228,7 @@
|
|||||||
{@const weeks = getWeeks(year)}
|
{@const weeks = getWeeks(year)}
|
||||||
{@const yt = totalsByYear.get(year)}
|
{@const yt = totalsByYear.get(year)}
|
||||||
{#if yt}
|
{#if yt}
|
||||||
<div class="mb-8">
|
<div class="mb-8" role="presentation">
|
||||||
<div class="flex items-baseline gap-3 mb-2">
|
<div class="flex items-baseline gap-3 mb-2">
|
||||||
<h2 class="text-lg font-semibold text-white">{year}</h2>
|
<h2 class="text-lg font-semibold text-white">{year}</h2>
|
||||||
<span class="text-sm text-zinc-400">
|
<span class="text-sm text-zinc-400">
|
||||||
@@ -232,9 +262,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{#each week as date}
|
{#each week as date}
|
||||||
<div
|
<div
|
||||||
class="w-[10px] h-[10px] rounded-[2px]"
|
class="w-[10px] h-[10px] rounded-[2px] {date && activitiesByDate.has(date) ? 'cursor-pointer' : ''}"
|
||||||
style="background:{cellColors.get(date) ?? '#27272a'}"
|
style="background:{cellColors.get(date) ?? '#27272a'}"
|
||||||
title={cellTitle(date)}
|
on:mouseenter={e => onCellEnter(date, e)}
|
||||||
|
on:mouseleave={onCellLeave}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -267,3 +298,36 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Day tooltip -->
|
||||||
|
{#if hoveredDate && tooltipActivities.length > 0}
|
||||||
|
<div
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium text-zinc-400 mb-2">
|
||||||
|
{new Date(hoveredDate + 'T12:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each tooltipActivities as a}
|
||||||
|
<a
|
||||||
|
href="{import.meta.env.BASE_URL}activity/{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}
|
||||||
|
|||||||
Reference in New Issue
Block a user