Files
streba/src/routes/segments/[id]/+page.svelte
T

229 lines
5.7 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 { 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>