229 lines
5.7 KiB
Svelte
229 lines
5.7 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import MapSlot from '$lib/components/MapSlot.svelte';
|
||
import StatTile from '$lib/components/StatTile.svelte';
|
||
import { mapState } from '$lib/map-state.svelte';
|
||
import { typeSlot } from '$lib/activity-colors';
|
||
import { fmtDate, fmtDistance, fmtElapsed, fmtElevation } from '$lib/format';
|
||
|
||
let { data } = $props();
|
||
const s = $derived(data.segment);
|
||
|
||
// efforts and leaderboards are split by activity type
|
||
const types = $derived.by(() => {
|
||
const counts = new Map<string, number>();
|
||
for (const effort of data.efforts) {
|
||
counts.set(effort.type, (counts.get(effort.type) ?? 0) + 1);
|
||
}
|
||
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([type]) => type);
|
||
});
|
||
let picked = $state<string | null>(null);
|
||
const selectedType = $derived(picked ?? types[0] ?? null);
|
||
const board = $derived(data.leaderboard.filter((row) => row.type === selectedType));
|
||
const efforts = $derived(data.efforts.filter((row) => row.type === selectedType));
|
||
|
||
// show the segment on the persistent map: synthetic negative id so it can
|
||
// be highlighted without colliding with activity ids
|
||
$effect(() => {
|
||
mapState.setDetailTrack({
|
||
id: -s.id,
|
||
name: s.name,
|
||
type: 'segment',
|
||
latlngs: data.latlngs
|
||
});
|
||
mapState.setHighlight(-s.id);
|
||
mapState.setFocus(JSON.parse(s.bounds));
|
||
return () => mapState.reset();
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{s.name} · Streba</title>
|
||
</svelte:head>
|
||
|
||
<MapSlot />
|
||
|
||
<a class="back" href="/segments">← Segments</a>
|
||
|
||
<div class="head">
|
||
<div>
|
||
<h1>{s.name}</h1>
|
||
<p class="page-sub">Segment · created {fmtDate(s.created_at.slice(0, 10))}</p>
|
||
</div>
|
||
{#if data.canDelete}
|
||
<form
|
||
method="POST"
|
||
action="?/delete"
|
||
use:enhance={({ cancel }) => {
|
||
if (!confirm('Delete this segment and all its efforts?')) cancel();
|
||
}}
|
||
>
|
||
<button class="btn danger" type="submit">Delete</button>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="kpis">
|
||
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
|
||
<StatTile label="Ascent" value={fmtElevation(s.elev_gain_m)} />
|
||
<StatTile
|
||
label={selectedType ? `Record (${selectedType})` : 'Record'}
|
||
value={board[0] ? fmtElapsed(board[0].best_s) : '–'}
|
||
detail={board[0]?.username ?? 'no timed efforts yet'}
|
||
/>
|
||
</div>
|
||
|
||
{#if types.length > 1}
|
||
<div class="type-chips" role="group" aria-label="Activity type">
|
||
{#each types as type (type)}
|
||
<button
|
||
class="chip"
|
||
class:on={selectedType === type}
|
||
style="--tc: var(--cat-{typeSlot(type)})"
|
||
onclick={() => (picked = type)}
|
||
>
|
||
{type}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<h2>Leaderboard{selectedType ? ` · ${selectedType}` : ''}</h2>
|
||
{#if board.length === 0}
|
||
<div class="card empty">No timed efforts yet - ride or run it with a GPS track.</div>
|
||
{:else}
|
||
<div class="card table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>#</th><th>Athlete</th><th>Best time</th><th>Attempts</th><th>Date</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each board as row, i (row.user_id + row.type)}
|
||
<tr class:me={row.user_id === data.myUserId}>
|
||
<td>{i + 1}</td>
|
||
<td><a href="/users/{row.username}">{row.username}</a></td>
|
||
<td class="time">{fmtElapsed(row.best_s)}</td>
|
||
<td>{row.attempts}</td>
|
||
<td>{row.date ? fmtDate(row.date) : '–'}</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if efforts.length > 0}
|
||
<h2>All efforts{selectedType ? ` · ${selectedType}` : ''} ({efforts.length})</h2>
|
||
<div class="card table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Athlete</th><th>Time</th><th>Date</th><th>Activity</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each efforts as effort (effort.id)}
|
||
<tr class:me={effort.user_id === data.myUserId}>
|
||
<td><a href="/users/{effort.username}">{effort.username}</a></td>
|
||
<td class="time">{effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : '–'}</td>
|
||
<td>{effort.date ? fmtDate(effort.date) : '–'}</td>
|
||
<td><a href="/activities/{effort.activity_id}">{effort.activity_name}</a></td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.back {
|
||
display: inline-block;
|
||
margin-bottom: 0.75rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
text-decoration: none;
|
||
padding: 0.25rem 0.6rem;
|
||
margin-left: -0.6rem;
|
||
border-radius: 0.45rem;
|
||
}
|
||
.back:hover {
|
||
background: var(--wash);
|
||
color: var(--text-primary);
|
||
}
|
||
.head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 1rem;
|
||
}
|
||
.kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||
gap: 0.75rem;
|
||
}
|
||
.type-chips {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
margin-top: 1.25rem;
|
||
}
|
||
.chip {
|
||
padding: 0.3rem 0.85rem;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
font: inherit;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
text-transform: capitalize;
|
||
cursor: pointer;
|
||
}
|
||
.chip:hover {
|
||
background: var(--wash);
|
||
}
|
||
.chip.on {
|
||
background: color-mix(in srgb, var(--tc) 14%, transparent);
|
||
border-color: transparent;
|
||
color: var(--tc);
|
||
font-weight: 600;
|
||
}
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.88rem;
|
||
}
|
||
th {
|
||
text-align: left;
|
||
font-weight: 500;
|
||
font-size: 0.78rem;
|
||
color: var(--text-muted);
|
||
padding: 0.6rem 1rem 0.35rem;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
td {
|
||
padding: 0.5rem 1rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
tbody tr:not(:last-child) td {
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
tr.me td {
|
||
background: var(--accent-wash);
|
||
}
|
||
td a {
|
||
color: var(--accent-strong);
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
}
|
||
.time {
|
||
font-weight: 600;
|
||
}
|
||
.empty {
|
||
padding: 1.75rem;
|
||
text-align: center;
|
||
color: var(--text-secondary);
|
||
}
|
||
</style>
|