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

464 lines
11 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 ElevationChart from "$lib/components/ElevationChart.svelte";
import SpeedChart from "$lib/components/SpeedChart.svelte";
import StatTile from "$lib/components/StatTile.svelte";
import { mapState } from "$lib/map-state.svelte";
import {
fmtDate,
fmtDistance,
fmtDuration,
fmtElapsed,
fmtElevation,
fmtSpeed,
} from "$lib/format";
let { data, form } = $props();
const a = $derived(data.activity);
const avgSpeed = $derived(
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null,
);
let editing = $state(false);
let saving = $state(false);
// create-segment panel: a distance slice of this track
let segmenting = $state(false);
let segStart = $state(0);
let segEnd = $state(0);
$effect(() => {
segEnd = Math.round(a.distance_m);
});
// chart hover -> marker on the map at the matching track position
function pointAt(d: number): [number, number] {
const trackD = data.trackD;
let lo = 0;
let hi = trackD.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (trackD[mid] < d) lo = mid;
else hi = mid;
}
return data.latlngs[d - trackD[lo] < trackD[hi] - d ? lo : hi];
}
let hoverD = $state<number | null>(null);
function chartHover(d: number | null) {
hoverD = d;
if (d === null) {
mapState.setHoverPoint(null);
return;
}
const [lat, lon] = pointAt(d);
mapState.setHoverPoint({ lat, lon });
}
// chart click -> fly the map to that spot
function chartSelect(d: number) {
const [lat, lon] = pointAt(d);
mapState.flyTo(lat, lon);
}
// the persistent map keeps the overview visible, dims the rest,
// and animates toward this activity
$effect(() => {
mapState.setDetailTrack({
id: a.id,
name: a.name,
username: a.username,
type: a.type,
date: a.date,
distance_m: a.distance_m,
latlngs: data.latlngs,
});
mapState.setHighlight(a.id);
mapState.setPeaks(data.bagged);
mapState.setFocus(JSON.parse(a.bounds));
return () => mapState.reset();
});
</script>
<svelte:head>
<title>{a.name} · Streba</title>
</svelte:head>
<a class="back" href="/">← Overview</a>
<div class="head">
{#if editing}
<form
class="edit-form"
method="POST"
action="?/update"
use:enhance={() => {
saving = true;
return async ({ update, result }) => {
saving = false;
if (result.type === "success") editing = false;
await update();
};
}}
>
<input
class="edit-name"
name="name"
required
maxlength="120"
value={a.name}
aria-label="Activity name"
/>
<input
class="edit-type"
name="type"
required
maxlength="40"
value={a.type}
list="activity-types"
aria-label="Activity type"
/>
<datalist id="activity-types">
{#each ["hike", "run", "ride", "ski", "climbing", "walk", "snowshoe"] as t (t)}
<option value={t}></option>
{/each}
</datalist>
<button class="btn" type="submit" disabled={saving}
>{saving ? "Saving…" : "Save"}</button
>
<button class="btn ghost" type="button" onclick={() => (editing = false)}
>Cancel</button
>
{#if form?.error}<span class="edit-error">{form.error}</span>{/if}
</form>
{:else}
<div>
<h1>
{a.name}
{#if data.canDelete}
<button
class="edit-btn"
title="Edit name and type"
aria-label="Edit name and type"
onclick={() => (editing = true)}
>
</button>
{/if}
</h1>
<p class="page-sub">
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(
a.date,
)} ·
<span class="type">{a.type}</span>
</p>
</div>
{/if}
{#if data.canDelete && !editing}
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (
!confirm("Delete this activity? Peaks it reached stay in your list.")
)
cancel();
}}
>
<button class="btn danger" type="submit">Delete</button>
</form>
{/if}
</div>
<div class="kpis">
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
<StatTile
label="Ascent"
value={fmtElevation(a.elev_gain_m)}
detail="↓ {fmtElevation(a.elev_loss_m)}"
/>
<StatTile
label="Moving time"
value={fmtDuration(a.moving_s ?? a.duration_s)}
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ""}
/>
{#if avgSpeed}
<StatTile label="Avg moving speed" value={avgSpeed} />
{:else}
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
{/if}
</div>
{#if data.bagged.length > 0}
<div class="card bagged">
⛰ Peaks reached:
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ", " : " "}<strong
>{peak.name}</strong
>
({peak.elevation_m} m){/each}
</div>
{/if}
{#if data.segmentEfforts.length > 0}
<div class="card segments">
<span class="seg-title">Segments on this activity:</span>
{#each data.segmentEfforts as effort, i (effort.segment_id)}{i > 0
? " · "
: " "}<a href="/segments/{effort.segment_id}">{effort.name}</a>{#if effort.elapsed_s !== null}&nbsp;({fmtElapsed(
effort.elapsed_s,
)}){/if}{/each}
</div>
{/if}
{#if data.canCreateSegment}
<div class="segment-create">
{#if segmenting}
<form class="card seg-form" method="POST" action="?/segment" use:enhance>
<div class="seg-sliders">
<label>
Start · {fmtDistance(Math.min(segStart, segEnd))}
<input
type="range"
name="start_d"
min="0"
max={Math.round(a.distance_m)}
step="10"
bind:value={segStart}
/>
</label>
<label>
End · {fmtDistance(Math.max(segStart, segEnd))}
<input
type="range"
name="end_d"
min="0"
max={Math.round(a.distance_m)}
step="10"
bind:value={segEnd}
/>
</label>
<span class="seg-len"
>Length: {fmtDistance(Math.abs(segEnd - segStart))}</span
>
</div>
<div class="seg-actions">
<input name="name" placeholder="Segment name" required maxlength="80" />
<button class="btn" type="submit">Create segment</button>
<button class="btn ghost" type="button" onclick={() => (segmenting = false)}
>Cancel</button
>
</div>
{#if form?.error}<p class="edit-error">{form.error}</p>{/if}
</form>
{:else}
<button class="btn ghost" onclick={() => (segmenting = true)}
> Create segment from this activity</button
>
{/if}
</div>
{/if}
<MapSlot />
{#if data.profile.length > 1}
<h2>Elevation profile</h2>
<div class="card chart">
<ElevationChart
profile={data.profile}
{hoverD}
onhover={chartHover}
onselect={chartSelect}
/>
<div class="chart-meta">
<span>Low {fmtElevation(a.elev_min_m)}</span>
<span>High {fmtElevation(a.elev_max_m)}</span>
</div>
</div>
{/if}
{#if data.timed.length > 2}
<h2>Speed</h2>
<div class="card chart">
<SpeedChart
timed={data.timed}
{hoverD}
onhover={chartHover}
onselect={chartSelect}
/>
</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;
}
.edit-btn {
border: none;
background: none;
font-size: 1rem;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem 0.4rem;
border-radius: 0.4rem;
vertical-align: middle;
}
.edit-btn:hover {
background: var(--wash);
color: var(--text-primary);
}
.edit-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
width: 100%;
}
.edit-form input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface-1);
color: var(--text-primary);
font: inherit;
}
.edit-name {
flex: 1;
min-width: 200px;
font-weight: 600;
}
.edit-type {
width: 120px;
text-transform: capitalize;
}
.edit-form input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.edit-error {
color: var(--critical);
font-size: 0.85rem;
}
.type {
text-transform: capitalize;
}
.segments {
margin-top: 1rem;
padding: 0.85rem 1.15rem;
border-left: 3px solid var(--accent);
color: var(--text-secondary);
}
.seg-title {
font-weight: 600;
color: var(--text-primary);
}
.segments a {
color: var(--accent-strong);
font-weight: 600;
text-decoration: none;
}
.segments a:hover {
text-decoration: underline;
}
.segment-create {
margin-top: 1rem;
}
.seg-form {
padding: 1.15rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.seg-sliders {
display: flex;
gap: 1.25rem;
align-items: flex-end;
flex-wrap: wrap;
}
.seg-sliders label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
flex: 1;
min-width: 180px;
}
.seg-sliders input[type="range"] {
accent-color: var(--accent);
}
.seg-len {
font-size: 0.85rem;
color: var(--text-primary);
font-weight: 600;
padding-bottom: 0.2rem;
}
.seg-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.seg-actions input {
flex: 1;
min-width: 180px;
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface-1);
color: var(--text-primary);
font: inherit;
}
.who {
font-weight: 600;
color: var(--accent-strong);
text-decoration: none;
}
.who:hover {
text-decoration: underline;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.75rem;
}
.bagged {
margin-top: 1rem;
padding: 0.85rem 1.15rem;
border-left: 3px solid var(--good);
color: var(--text-secondary);
}
.bagged strong {
color: var(--text-primary);
}
.chart {
padding: 1rem 1rem 0.5rem;
}
.chart-meta {
display: flex;
gap: 1.25rem;
padding: 0.5rem 0.25rem 0.6rem;
font-size: 0.8rem;
color: var(--text-muted);
}
</style>