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
This commit is contained in:
@@ -2437,6 +2437,25 @@ async def get_segments(
|
||||
} for s in segs])
|
||||
|
||||
|
||||
@app.get("/api/segments/{segment_id}")
|
||||
async def get_segment(segment_id: str) -> JSONResponse:
|
||||
"""Return metadata for a single segment."""
|
||||
dd = _get_data_dir()
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
return JSONResponse({
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"polyline": seg.polyline,
|
||||
"distance_m": seg.distance_m,
|
||||
"bbox": seg.bbox,
|
||||
"created_by": seg.created_by,
|
||||
"created_at": _seg_store._iso(seg.created_at),
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/segments")
|
||||
async def create_segment(
|
||||
body: CreateSegmentRequest,
|
||||
@@ -2565,6 +2584,70 @@ async def trigger_detect(
|
||||
return JSONResponse({"ok": True, "efforts_found": total})
|
||||
|
||||
|
||||
@app.get("/api/activities/{activity_id}/segment_efforts")
|
||||
async def activity_segment_efforts(
|
||||
activity_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return segment efforts that belong to a specific activity for the logged-in user."""
|
||||
user = _require_user(bincio_session)
|
||||
dd = _get_data_dir()
|
||||
efforts_dir = dd / user.handle / "segment_efforts"
|
||||
result = []
|
||||
if efforts_dir.exists():
|
||||
import json as _json
|
||||
for ef_file in sorted(efforts_dir.glob("*.json")):
|
||||
seg_id = ef_file.stem
|
||||
all_efforts = _seg_store.load_efforts(dd, user.handle, seg_id)
|
||||
matching = [e for e in all_efforts if e.activity_id == activity_id]
|
||||
if not matching:
|
||||
continue
|
||||
seg = _seg_store.load_segment(dd, seg_id)
|
||||
if not seg:
|
||||
continue
|
||||
pr_elapsed = min(e.elapsed_s for e in all_efforts)
|
||||
for eff in matching:
|
||||
result.append({
|
||||
"segment_id": seg.id,
|
||||
"segment_name": seg.name,
|
||||
"segment_distance_m": seg.distance_m,
|
||||
"elapsed_s": eff.elapsed_s,
|
||||
"pr_elapsed_s": pr_elapsed,
|
||||
"started_at": _seg_store._iso(eff.started_at),
|
||||
})
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/users/{handle}/segment_summary")
|
||||
async def user_segment_summary(handle: str) -> JSONResponse:
|
||||
"""Public endpoint: segments where this user has efforts, with best time and count."""
|
||||
dd = _get_data_dir()
|
||||
efforts_dir = dd / handle / "segment_efforts"
|
||||
result = []
|
||||
if efforts_dir.exists():
|
||||
for ef_file in sorted(efforts_dir.glob("*.json")):
|
||||
seg_id = ef_file.stem
|
||||
efforts = _seg_store.load_efforts(dd, handle, seg_id)
|
||||
if not efforts:
|
||||
continue
|
||||
seg = _seg_store.load_segment(dd, seg_id)
|
||||
if not seg:
|
||||
continue
|
||||
best = min(efforts, key=lambda e: e.elapsed_s)
|
||||
result.append({
|
||||
"segment": {
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"distance_m": seg.distance_m,
|
||||
},
|
||||
"best_elapsed_s": best.elapsed_s,
|
||||
"effort_count": len(efforts),
|
||||
})
|
||||
result.sort(key=lambda x: x["segment"]["name"].lower())
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
# ── Feedback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { ActivitySummary, ActivityDetail, AthleteZones, Timeseries } from '../lib/types';
|
||||
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
||||
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';
|
||||
@@ -27,6 +27,15 @@
|
||||
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 = '';
|
||||
@@ -38,6 +47,11 @@
|
||||
.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');
|
||||
@@ -373,3 +387,35 @@
|
||||
</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}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import MmpChart from './MmpChart.svelte';
|
||||
import RecordsView from './RecordsView.svelte';
|
||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||
import { isUnlisted } from '../lib/format';
|
||||
import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format';
|
||||
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
||||
|
||||
export let base: string = '/';
|
||||
@@ -12,6 +12,8 @@
|
||||
export let indexUrl: string = '';
|
||||
/** Explicit athlete.json URL for multi-user per-user pages. */
|
||||
export let athleteUrl: string = '';
|
||||
/** Handle whose segment summary to show. Parsed from athleteUrl if blank. */
|
||||
export let handle: string = '';
|
||||
|
||||
let athlete: AthleteJson | null = null;
|
||||
let activities: ActivitySummary[] = [];
|
||||
@@ -19,10 +21,19 @@
|
||||
let error: string | null = null;
|
||||
let drawerOpen = false;
|
||||
|
||||
type Tab = 'power' | 'records' | 'profile';
|
||||
type Tab = 'power' | 'records' | 'segments' | 'profile';
|
||||
let activeTab: Tab = 'power';
|
||||
let mounted = false;
|
||||
|
||||
interface SegmentSummaryItem {
|
||||
segment: { id: string; name: string; sport: string | null; distance_m: number };
|
||||
best_elapsed_s: number;
|
||||
effort_count: number;
|
||||
}
|
||||
let segmentSummary: SegmentSummaryItem[] = [];
|
||||
let segmentsLoading = false;
|
||||
let segmentsHandle = '';
|
||||
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||
const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true';
|
||||
|
||||
@@ -33,11 +44,36 @@
|
||||
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
||||
}
|
||||
|
||||
$: if (activeTab === 'segments' && segmentsHandle && segmentSummary.length === 0 && !segmentsLoading) {
|
||||
segmentsLoading = true;
|
||||
fetch(`/api/users/${segmentsHandle}/segment_summary`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(d => { segmentSummary = d; })
|
||||
.catch(() => {})
|
||||
.finally(() => { segmentsLoading = false; });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const TABS: Tab[] = ['power', 'records', 'profile'];
|
||||
const TABS: Tab[] = ['power', 'records', 'segments', 'profile'];
|
||||
const rawTab = new URLSearchParams(window.location.search).get('tab');
|
||||
activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
||||
mounted = true;
|
||||
|
||||
// Resolve handle for the segments endpoint
|
||||
if (handle) {
|
||||
segmentsHandle = handle;
|
||||
} else if (athleteUrl) {
|
||||
// Parse from e.g. "/data/ccbas/_merged/athlete.json"
|
||||
const m = athleteUrl.match(/\/data\/([^/]+)\//);
|
||||
if (m) segmentsHandle = m[1];
|
||||
}
|
||||
if (!segmentsHandle) {
|
||||
// Fall back to /api/me for the self-athlete page
|
||||
try {
|
||||
const r = await fetch('/api/me', { credentials: 'include' });
|
||||
if (r.ok) { const u = await r.json(); segmentsHandle = u.handle ?? ''; }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
try {
|
||||
const [athleteData, index] = await Promise.all([
|
||||
loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined),
|
||||
@@ -81,6 +117,7 @@
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'power', label: 'Power Curve' },
|
||||
{ key: 'records', label: 'Records' },
|
||||
{ key: 'segments', label: 'Segments' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
];
|
||||
</script>
|
||||
@@ -138,6 +175,44 @@
|
||||
{:else if activeTab === 'records'}
|
||||
<RecordsView {athlete} {base} />
|
||||
|
||||
<!-- Segments tab -->
|
||||
{:else if activeTab === 'segments'}
|
||||
{#if segmentsLoading}
|
||||
<p class="text-zinc-500 text-sm">Loading segments…</p>
|
||||
{:else if segmentSummary.length === 0}
|
||||
<p class="text-zinc-500 text-sm">No segment efforts yet.</p>
|
||||
{:else}
|
||||
<div class="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">Segment</th>
|
||||
<th class="px-4 py-2 hidden sm:table-cell">Distance</th>
|
||||
<th class="px-4 py-2">Best time</th>
|
||||
<th class="px-4 py-2 text-right">Efforts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each segmentSummary as row}
|
||||
<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/{row.segment.id}/"
|
||||
class="text-white hover:text-blue-400 transition-colors font-medium">
|
||||
{#if row.segment.sport}
|
||||
<span class="mr-1.5">{sportIcon(row.segment.sport as any)}</span>
|
||||
{/if}{row.segment.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-zinc-400 hidden sm:table-cell">{formatDistance(row.segment.distance_m)}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white">{formatElapsed(row.best_elapsed_s)}</td>
|
||||
<td class="px-4 py-2.5 text-zinc-400 text-right">{row.effort_count}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Profile tab -->
|
||||
{:else if activeTab === 'profile'}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { formatDistance, formatDate, formatElapsed, sportIcon } from '../lib/format';
|
||||
|
||||
export let base: string = '/';
|
||||
export let segmentId: string = '';
|
||||
|
||||
interface Segment {
|
||||
id: string;
|
||||
name: string;
|
||||
sport: string | null;
|
||||
distance_m: number;
|
||||
polyline: [number, number][];
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Effort {
|
||||
activity_id: string;
|
||||
started_at: string;
|
||||
elapsed_s: number;
|
||||
avg_speed_kmh: number | null;
|
||||
avg_hr_bpm: number | null;
|
||||
avg_power_w: number | null;
|
||||
np_power_w: number | null;
|
||||
}
|
||||
|
||||
let segment: Segment | null = null;
|
||||
let efforts: Effort[] = [];
|
||||
let loading = true;
|
||||
let loggedIn = true;
|
||||
let mapEl: HTMLDivElement;
|
||||
let map: any;
|
||||
let mapReady = false;
|
||||
let retriggering = false;
|
||||
let retriggerMsg: string | null = null;
|
||||
|
||||
$: prElapsed = efforts.length ? Math.min(...efforts.map(e => e.elapsed_s)) : null;
|
||||
|
||||
function delta(elapsed: number): number {
|
||||
return prElapsed != null ? elapsed - prElapsed : 0;
|
||||
}
|
||||
|
||||
function formatDelta(d: number): string {
|
||||
if (d === 0) return 'PR';
|
||||
const abs = Math.abs(d);
|
||||
const m = Math.floor(abs / 60);
|
||||
const s = abs % 60;
|
||||
const sign = d > 0 ? '+' : '−';
|
||||
return m > 0 ? `${sign}${m}m${s.toString().padStart(2, '0')}s` : `${sign}${s}s`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const [segRes, effRes] = await Promise.allSettled([
|
||||
fetch(`/api/segments/${segmentId}`, { credentials: 'include' }),
|
||||
fetch(`/api/segments/${segmentId}/efforts`, { credentials: 'include' }),
|
||||
]);
|
||||
|
||||
if (segRes.status === 'fulfilled' && segRes.value.ok) {
|
||||
segment = await segRes.value.json();
|
||||
}
|
||||
if (effRes.status === 'fulfilled') {
|
||||
if (effRes.value.status === 401) {
|
||||
loggedIn = false;
|
||||
} else if (effRes.value.ok) {
|
||||
efforts = await effRes.value.json();
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
if (segment) setTimeout(initMap, 0);
|
||||
});
|
||||
|
||||
onDestroy(() => map?.remove());
|
||||
|
||||
function initMap() {
|
||||
if (!mapEl || !segment) return;
|
||||
const lons = segment.polyline.map(([, lon]) => lon);
|
||||
const lats = segment.polyline.map(([lat]) => lat);
|
||||
|
||||
map = new maplibregl.Map({
|
||||
container: mapEl,
|
||||
style: 'https://tiles.openfreemap.org/styles/positron',
|
||||
bounds: [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
||||
fitBoundsOptions: { padding: 60 },
|
||||
attributionControl: false,
|
||||
});
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('seg', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: segment!.polyline.map(([lat, lon]) => [lon, lat]),
|
||||
},
|
||||
},
|
||||
});
|
||||
map.addLayer({ id: 'seg-line', type: 'line', source: 'seg',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#f59e0b', 'line-width': 5 } });
|
||||
|
||||
// Start/end markers
|
||||
const start = segment!.polyline[0];
|
||||
const end = segment!.polyline[segment!.polyline.length - 1];
|
||||
for (const [pt, label, color] of [[start, 'S', '#22c55e'], [end, 'E', '#ef4444']] as const) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `width:20px;height:20px;border-radius:50%;background:${color};color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)`;
|
||||
el.textContent = label;
|
||||
new maplibregl.Marker({ element: el }).setLngLat([pt[1], pt[0]]).addTo(map);
|
||||
}
|
||||
|
||||
mapReady = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function retrigger() {
|
||||
retriggering = true;
|
||||
retriggerMsg = null;
|
||||
try {
|
||||
const r = await fetch(`/api/segments/${segmentId}/detect`, {
|
||||
method: 'POST', credentials: 'include',
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
retriggerMsg = `Found ${d.efforts_found} effort${d.efforts_found !== 1 ? 's' : ''}.`;
|
||||
// Reload efforts
|
||||
const er = await fetch(`/api/segments/${segmentId}/efforts`, { credentials: 'include' });
|
||||
if (er.ok) efforts = await er.json();
|
||||
} else {
|
||||
retriggerMsg = d.detail ?? 'Detection failed.';
|
||||
}
|
||||
} catch {
|
||||
retriggerMsg = 'Could not reach server.';
|
||||
}
|
||||
retriggering = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<!-- Back -->
|
||||
<a href="{base}segments/" class="text-zinc-500 hover:text-white transition-colors text-sm">← Segments</a>
|
||||
|
||||
{#if loading}
|
||||
<div class="mt-6 space-y-4">
|
||||
<div class="h-7 w-48 bg-zinc-800 rounded animate-pulse"></div>
|
||||
<div class="h-64 bg-zinc-800 rounded-xl animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
{:else if !segment}
|
||||
<p class="mt-6 text-red-400 text-sm">Segment not found.</p>
|
||||
|
||||
{:else}
|
||||
<!-- Title + metadata -->
|
||||
<div class="mt-4 mb-4 flex items-start gap-3">
|
||||
{#if segment.sport}
|
||||
<span class="text-2xl mt-0.5">{sportIcon(segment.sport as any)}</span>
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">{segment.name}</h1>
|
||||
<p class="text-sm text-zinc-500 mt-0.5">
|
||||
{formatDistance(segment.distance_m)}
|
||||
{#if segment.sport} · {segment.sport}{/if}
|
||||
· by {segment.created_by}
|
||||
· {formatDate(segment.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<div class="rounded-xl overflow-hidden mb-6" style="height: 280px;">
|
||||
<div bind:this={mapEl} class="w-full h-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Efforts -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-base font-semibold text-white">Your efforts</h2>
|
||||
{#if loggedIn}
|
||||
<div class="flex items-center gap-3">
|
||||
{#if retriggerMsg}
|
||||
<span class="text-xs text-zinc-400">{retriggerMsg}</span>
|
||||
{/if}
|
||||
<button
|
||||
on:click={retrigger}
|
||||
disabled={retriggering}
|
||||
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors disabled:opacity-40"
|
||||
>{retriggering ? 'Scanning…' : 'Rescan activities'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !loggedIn}
|
||||
<p class="text-zinc-500 text-sm">
|
||||
<a href="{base}login/" class="text-blue-400 hover:text-blue-300">Log in</a> to see your efforts on this segment.
|
||||
</p>
|
||||
|
||||
{:else if efforts.length === 0}
|
||||
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-6 text-center">
|
||||
<p class="text-zinc-500 text-sm">No efforts yet.</p>
|
||||
<p class="text-zinc-600 text-xs mt-1">Upload an activity that passes through this segment, or use "Rescan activities" to check existing ones.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="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">Date</th>
|
||||
<th class="px-4 py-2">Time</th>
|
||||
<th class="px-4 py-2">Δ PR</th>
|
||||
<th class="px-4 py-2 hidden sm:table-cell">Avg speed</th>
|
||||
<th class="px-4 py-2 hidden sm:table-cell">Avg HR</th>
|
||||
<th class="px-4 py-2 hidden md:table-cell">Avg power</th>
|
||||
<th class="px-4 py-2 hidden md:table-cell">NP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each efforts as e (e.activity_id + e.started_at)}
|
||||
{@const d = delta(e.elapsed_s)}
|
||||
{@const isPR = d === 0}
|
||||
<tr
|
||||
class="border-b border-zinc-800/50 hover:bg-zinc-800/50 transition-colors"
|
||||
class:bg-green-950={isPR}
|
||||
class:hover:bg-green-900={isPR}
|
||||
>
|
||||
<td class="px-4 py-2">
|
||||
<a href="{base}activity/{e.activity_id}/" class="text-blue-400 hover:text-blue-300 transition-colors">
|
||||
{formatDate(e.started_at)}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-white font-mono">{formatElapsed(e.elapsed_s)}</td>
|
||||
<td class="px-4 py-2 font-medium text-xs"
|
||||
class:text-green-400={isPR}
|
||||
class:text-zinc-400={!isPR}>
|
||||
{formatDelta(d)}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-zinc-300 hidden sm:table-cell">
|
||||
{e.avg_speed_kmh != null ? `${e.avg_speed_kmh.toFixed(1)} km/h` : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-zinc-300 hidden sm:table-cell">
|
||||
{e.avg_hr_bpm != null ? `${e.avg_hr_bpm} bpm` : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-zinc-300 hidden md:table-cell">
|
||||
{e.avg_power_w != null ? `${e.avg_power_w} W` : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-zinc-300 hidden md:table-cell">
|
||||
{e.np_power_w != null ? `${e.np_power_w} W` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import SegmentsView from './SegmentsView.svelte';
|
||||
import SegmentDetail from './SegmentDetail.svelte';
|
||||
|
||||
export let base: string = '/';
|
||||
|
||||
let segmentId: string | null = null;
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
// /segments/{id}/ → last non-empty path part is the ID
|
||||
// /segments/ → last part is 'segments', no detail view
|
||||
// /segments/new/ → skip (handled by its own page)
|
||||
const parts = window.location.pathname.replace(/\/$/, '').split('/').filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last !== 'segments' && last !== 'new') {
|
||||
segmentId = last;
|
||||
}
|
||||
mounted = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if mounted}
|
||||
{#if segmentId}
|
||||
<SegmentDetail {base} {segmentId} />
|
||||
{:else}
|
||||
<div class="flex flex-col" style="height: calc(100vh - 48px);">
|
||||
<SegmentsView {base} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -26,6 +26,16 @@ export function formatDuration(s: number | null): string {
|
||||
return `${m}m ${sec.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
/** Compact m:ss or h:mm:ss for segment effort tables. */
|
||||
export function formatElapsed(s: number): string {
|
||||
s = Math.floor(s);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatSpeed(kmh: number | null): string {
|
||||
if (kmh == null) return '—';
|
||||
return `${kmh.toFixed(1)} km/h`;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import SegmentsView from '../../components/SegmentsView.svelte';
|
||||
import SegmentsPage from '../../components/SegmentsPage.svelte';
|
||||
---
|
||||
<Base title="Segments — BincioActivity">
|
||||
<div class="flex flex-col" style="height: calc(100vh - 48px);">
|
||||
<SegmentsView base={import.meta.env.BASE_URL} client:only="svelte" />
|
||||
</div>
|
||||
<SegmentsPage base={import.meta.env.BASE_URL} client:only="svelte" />
|
||||
</Base>
|
||||
|
||||
@@ -36,7 +36,7 @@ const athleteUrl = `${mergedBase}athlete.json`;
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<AthleteView {base} {indexUrl} {athleteUrl} client:only="svelte" />
|
||||
<AthleteView {base} {indexUrl} {athleteUrl} {handle} client:only="svelte" />
|
||||
</Base>
|
||||
<script define:vars={{ handle }}>
|
||||
function applyMeCheck(me) {
|
||||
|
||||
Reference in New Issue
Block a user