Files
bincio-activity/site/src/components/ActivityDetail.svelte
T
Davide Scaini f2075e29d2 Segments Phase 4: detail page, activity efforts, athlete tab, new APIs
New API endpoints:
- GET /api/segments/{id} — single segment metadata
- GET /api/activities/{id}/segment_efforts — efforts for an activity (auth)
- GET /api/users/{handle}/segment_summary — public best time + count per segment

New components:
- SegmentDetail.svelte — map + metadata + effort table (with PR/Δ) + rescan button
- SegmentsPage.svelte — URL router: shows detail when /segments/{id}/, list otherwise

Updated:
- segments/index.astro — now uses SegmentsPage router
- nginx-activity.conf — add /segments/ try_files rule for client-side routing
- ActivityDetail.svelte — segment efforts block below laps
- AthleteView.svelte — Segments tab with best time + effort count per segment
- format.ts — add formatElapsed() for compact m:ss display
2026-05-13 08:09:24 +02:00

422 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 { marked } from 'marked';
import DOMPurify from 'dompurify';
import type { ActivitySummary, ActivityDetail, AthleteZones, Timeseries } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, formatElapsed, sportIcon, sportLabel, sportColor } from '../lib/format';
import ActivityMap from './ActivityMap.svelte';
import ActivityCharts from './ActivityCharts.svelte';
import EditDrawer from './EditDrawer.svelte';
import { loadActivity, loadTimeseries } from '../lib/dataloader';
export let activity: ActivitySummary;
export let base: string = '/';
export let athlete: AthleteZones | null = null;
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
const editGloballyEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true';
let currentHandle: string | null = null;
$: editEnabled = editGloballyEnabled && currentHandle !== null && currentHandle === activity.handle;
let detail: ActivityDetail | null = null;
let timeseries: Timeseries | null = null;
let timeseriesLoading = false;
let error = '';
let hoveredIdx: number | null = null;
let editOpen = false;
let lightboxIndex: number | null = null;
interface SegmentEffortHit {
segment_id: string;
segment_name: string;
segment_distance_m: number;
elapsed_s: number;
pr_elapsed_s: number;
}
let segmentEfforts: SegmentEffortHit[] = [];
// Local overrides applied immediately after a save (no re-fetch needed)
let localTitle = '';
let localDescription = '';
$: displayTitle = localTitle || activity.title;
onMount(async () => {
fetch('/api/me', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(u => { if (u?.handle) currentHandle = u.handle; })
.catch(() => {});
fetch(`/api/activities/${activity.id}/segment_efforts`, { credentials: 'include' })
.then(r => r.ok ? r.json() : [])
.then(d => { segmentEfforts = d; })
.catch(() => {});
try {
detail = await loadActivity(activity.id, activity.detail_url ?? '', base);
if (!detail) throw new Error('Activity not found');
// Use embedded timeseries (IDB activities) or lazy-fetch from URL
if (detail.timeseries) {
timeseries = detail.timeseries;
} else if (detail.timeseries_url) {
timeseriesLoading = true;
timeseries = await loadTimeseries(detail.timeseries_url, activity.detail_url ?? '', base);
timeseriesLoading = false;
}
} catch (e: any) {
error = e.message;
timeseriesLoading = false;
}
});
function onSaved(e: CustomEvent<{ title: string; description: string }>) {
editOpen = false;
localTitle = e.detail.title;
localDescription = e.detail.description;
}
$: trackUrl = activity.track_url
? (activity.track_url.startsWith('http') || activity.track_url.startsWith('/') ? activity.track_url : `${base}data/${activity.track_url}`)
: null;
$: color = sportColor(activity.sport);
function lightboxPrev() { if (lightboxIndex !== null) lightboxIndex = (lightboxIndex - 1 + galleryImages.length) % galleryImages.length; }
function lightboxNext() { if (lightboxIndex !== null) lightboxIndex = (lightboxIndex + 1) % galleryImages.length; }
function onKeydown(e: KeyboardEvent) {
if (lightboxIndex === null) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); lightboxPrev(); }
if (e.key === 'ArrowRight') { e.preventDefault(); lightboxNext(); }
if (e.key === 'Escape') { lightboxIndex = null; }
}
$: rawDescription = localDescription || detail?.description || '';
$: descriptionHtml = (() => {
if (!rawDescription) return '';
// Strip local image refs before marked sees them. marked only parses ![alt](url) as an
// image when the URL has no spaces — filenames like "WhatsApp Image 2026.jpg" are left
// as literal text instead. The lazy .*? anchored to the image extension handles filenames
// with spaces and nested parens (e.g. "file(2).jpg") correctly.
const stripped = rawDescription
.replace(/!\[[^\]]*\]\((?!https?:\/\/|\/|data:).*?\.(?:jpe?g|png|gif|webp|bmp|avif|heic)\)/gi, '')
.trim();
if (!stripped) return '';
const renderer = new marked.Renderer();
// Any remaining remote images render inline; local ones (shouldn't exist after strip) are suppressed
renderer.image = ({ href, title, text }) => {
const isLocal = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:');
if (isLocal) return '';
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href ?? ''}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`;
};
return DOMPurify.sanitize(marked(stripped, { renderer }) as string);
})();
// Derive image dir from detail_url so multi-user paths resolve correctly.
// Relative: "dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
// Absolute: "/data/dave/_merged/activities/foo.json" → "/data/dave/_merged/activities/images/{id}/"
$: imageBase = (() => {
const du = activity.detail_url ?? '';
const dir = du.startsWith('http') || du.startsWith('/')
? du.substring(0, du.lastIndexOf('/') + 1)
: du.includes('/')
? `${base}data/${du.substring(0, du.lastIndexOf('/') + 1)}`
: `${base}data/activities/`;
return `${dir}images/${activity.id}/`;
})();
$: galleryImages = (detail?.custom as any)?.images as string[] ?? [];
// TODO(cleanup): fallback NP from timeseries — remove once all activities have been
// re-extracted with np_power_w baked into the JSON. Mirrors metrics.py → _np_power().
function computeNpFromTimeseries(ts: Timeseries): number | null {
const { t, power_w } = ts;
if (!power_w || !t || t.length < 30) return null;
if (!power_w.some(v => v != null)) return null;
const sparse = new Map<number, number>();
for (let i = 0; i < t.length; i++) {
if (power_w[i] != null) sparse.set(t[i], power_w[i]!);
}
if (sparse.size < 2) return null;
const tMin = Math.min(...sparse.keys());
const tMax = Math.max(...sparse.keys());
if (tMax - tMin > 7 * 24 * 3600) return null;
const dense: number[] = [];
for (let i = 0; i <= tMax - tMin; i++) dense.push(sparse.get(tMin + i) ?? 0);
const win = 30;
if (dense.length < win) return null;
const half = Math.floor(win / 2);
let windowSum = dense.slice(0, win).reduce((a, b) => a + b, 0);
const fourthPowers: number[] = [];
for (let i = half; i < dense.length - half; i++) {
fourthPowers.push((windowSum / win) ** 4);
if (i + half + 1 < dense.length) windowSum += dense[i + half + 1] - dense[i - half];
}
if (!fourthPowers.length) return null;
return Math.round((fourthPowers.reduce((a, b) => a + b, 0) / fourthPowers.length) ** 0.25);
}
$: npPower = detail?.np_power_w ?? (timeseries ? computeNpFromTimeseries(timeseries) : null);
const stat = (label: string, value: string, key?: string) => ({ label, value, key });
$: hiddenStats = new Set<string>((detail?.custom as any)?.hide_stats ?? []);
$: stats = [
stat('Distance', formatDistance(activity.distance_m)),
stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)),
stat('Elevation ↑', formatElevation(activity.elevation_gain_m), 'elevation'),
stat('Avg speed', formatSpeed(activity.avg_speed_kmh), 'speed'),
stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'),
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` : '—', 'heart_rate'),
stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'),
...(activity.avg_power_w != null ? [
stat('Avg power', `${activity.avg_power_w} W`, 'power'),
stat('NP', npPower != null ? `${npPower} W` : '—', 'power'),
] : []),
].filter(s => !s.key || !hiddenStats.has(s.key));
</script>
<svelte:window on:keydown={onKeydown} />
{#if editOpen && editEnabled}
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} on:close={() => editOpen = false} on:deleted={() => { window.location.href = base; }} />
{/if}
<!-- Lightbox -->
{#if lightboxIndex !== null}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
on:click={() => lightboxIndex = null}
on:keydown={onKeydown}
>
<!-- Prev -->
{#if galleryImages.length > 1}
<button
class="absolute left-4 top-1/2 -translate-y-1/2 text-white/60 hover:text-white text-3xl px-3 py-6 transition-colors z-10"
on:click|stopPropagation={lightboxPrev}
aria-label="Previous"
></button>
{/if}
<button
type="button"
class="contents"
on:click|stopPropagation
aria-label="Image {lightboxIndex + 1} of {galleryImages.length}"
>
<img
src={imageBase + galleryImages[lightboxIndex]}
alt={galleryImages[lightboxIndex]}
class="max-h-[90vh] max-w-[90vw] rounded-lg shadow-2xl object-contain"
/>
</button>
<!-- Next -->
{#if galleryImages.length > 1}
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/60 hover:text-white text-3xl px-3 py-6 transition-colors z-10"
on:click|stopPropagation={lightboxNext}
aria-label="Next"
></button>
{/if}
<!-- Counter + filename -->
<div class="absolute bottom-6 left-1/2 -translate-x-1/2 text-white/50 text-xs text-center">
<p>{galleryImages[lightboxIndex]}</p>
{#if galleryImages.length > 1}
<p class="mt-0.5">{lightboxIndex + 1} / {galleryImages.length}</p>
{/if}
</div>
<!-- Close -->
<button
class="absolute top-4 right-5 text-white/50 hover:text-white text-2xl transition-colors"
on:click={() => lightboxIndex = null}
aria-label="Close"
>×</button>
</div>
{/if}
<!-- Header -->
<div class="flex items-start gap-4 mb-6">
<button on:click={() => history.back()} class="text-zinc-500 hover:text-white transition-colors mt-1 shrink-0 cursor-pointer">
← Back
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
style="background:{color}22;color:{color}"
>
{sportIcon(activity.sport)} {sportLabel(activity.sport)}
</span>
{#if activity.sub_sport && activity.sub_sport !== 'generic'}
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
style="background:{color}11;color:{color}cc"
>
{sportLabel(activity.sport, activity.sub_sport).split(' ')[0]}
</span>
{/if}
<span class="text-xs text-zinc-500">
{formatDate(activity.started_at)} · {formatTime(activity.started_at)}{#if activity.handle} · <a href="{base}u/{activity.handle}/" class="hover:text-zinc-300 transition-colors">@{activity.handle}</a>{/if}
</span>
</div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white">{displayTitle}</h1>
{#if editEnabled}
<button
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"
on:click={() => editOpen = true}
>
Edit
</button>
{/if}
{#if trackUrl}
<a
href="{base}segments/new/?activity={activity.id}"
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"
>+ segment</a>
{/if}
</div>
{#if descriptionHtml}
<div class="text-zinc-400 mt-2 text-sm leading-relaxed [&_img]:rounded-lg [&_img]:my-2 [&_p]:my-1 [&_a]:text-blue-400">
{@html descriptionHtml}
</div>
{/if}
</div>
</div>
<!-- Photo gallery -->
{#if galleryImages.length}
<div class="mb-4 grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
{#each galleryImages as img, i}
<button
class="relative overflow-hidden rounded-lg bg-zinc-800 aspect-square hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-blue-500"
on:click={() => lightboxIndex = i}
aria-label="Open photo {i + 1}"
>
<img
src={imageBase + img}
alt={img}
class="w-full h-full object-cover"
loading="lazy"
/>
</button>
{/each}
</div>
{/if}
<!-- Map + Stats split -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4">
<!-- Map -->
<div class="h-[400px] lg:h-[420px] rounded-xl overflow-hidden bg-zinc-800">
{#if trackUrl}
<ActivityMap
{trackUrl}
{timeseries}
bbox={detail?.bbox ?? null}
initialCoords={activity.preview_coords}
accentColor={color}
bind:hoveredIdx
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm">
No GPS track
</div>
{/if}
</div>
<!-- Stats panel -->
<div class="grid grid-cols-2 lg:grid-cols-1 gap-px bg-zinc-800 rounded-xl overflow-hidden">
{#each stats as s}
<div class="bg-zinc-900 px-4 py-3">
<p class="text-2xl font-bold text-white">{s.value}</p>
<p class="text-xs text-zinc-500">{s.label}</p>
</div>
{/each}
{#if detail?.gear}
<div class="bg-zinc-900 px-4 py-3 col-span-2 lg:col-span-1">
<p class="text-sm font-medium text-zinc-300">{detail.gear}</p>
<p class="text-xs text-zinc-500">Gear</p>
</div>
{/if}
</div>
</div>
<!-- Charts -->
{#if error}
<p class="text-red-400 text-sm mt-4">{error}</p>
{:else if timeseries && timeseries.t.length > 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<ActivityCharts {timeseries} bind:hoveredIdx {athlete} />
</div>
{:else if !detail || timeseriesLoading}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div>
{/if}
<!-- Laps -->
{#if detail?.laps?.length}
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-zinc-800">
<tr class="text-left text-zinc-500 text-xs">
<th class="px-4 py-2">Lap</th>
<th class="px-4 py-2">Distance</th>
<th class="px-4 py-2">Time</th>
<th class="px-4 py-2">Avg speed</th>
<th class="px-4 py-2">Avg HR</th>
</tr>
</thead>
<tbody>
{#each detail.laps as lap}
<tr class="border-b border-zinc-800/50 hover:bg-zinc-800/50">
<td class="px-4 py-2 text-zinc-400">#{lap.index + 1}</td>
<td class="px-4 py-2 text-white">{formatDistance(lap.distance_m)}</td>
<td class="px-4 py-2 text-white">{formatDuration(lap.duration_s)}</td>
<td class="px-4 py-2 text-white">{formatSpeed(lap.avg_speed_kmh)}</td>
<td class="px-4 py-2 text-white">{lap.avg_hr_bpm ? `${lap.avg_hr_bpm} bpm` : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Segment efforts -->
{#if segmentEfforts.length > 0}
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div class="px-4 py-3 border-b border-zinc-800">
<h3 class="text-sm font-semibold text-white">Segments</h3>
</div>
<table class="w-full text-sm">
<tbody>
{#each segmentEfforts as hit}
{@const isPR = hit.elapsed_s === hit.pr_elapsed_s}
{@const delta = hit.elapsed_s - hit.pr_elapsed_s}
<tr class="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-2.5">
<a href="{base}segments/{hit.segment_id}/"
class="text-white hover:text-blue-400 transition-colors font-medium">
{hit.segment_name}
</a>
<span class="text-zinc-500 text-xs ml-2">{formatDistance(hit.segment_distance_m)}</span>
</td>
<td class="px-4 py-2.5 font-mono text-white text-right">{formatElapsed(hit.elapsed_s)}</td>
<td class="px-4 py-2.5 text-right text-xs font-medium w-16"
class:text-green-400={isPR}
class:text-zinc-500={!isPR}>
{#if isPR}PR{:else}+{Math.floor(delta/60) > 0 ? `${Math.floor(delta/60)}m${(delta%60).toString().padStart(2,'0')}s` : `${delta}s`}{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}