diff --git a/src/lib/components/ElevationChart.svelte b/src/lib/components/ElevationChart.svelte index 65d2600..44ebc62 100644 --- a/src/lib/components/ElevationChart.svelte +++ b/src/lib/components/ElevationChart.svelte @@ -1,5 +1,13 @@ @@ -12,14 +35,17 @@

Your activities

- {data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}. + {shown.count} recorded {shown.count === 1 ? 'activity' : 'activities'}{suffix}. + {#if selectedYear} + + {/if}

{#if data.activities.length > 0}
- - - + + +
{#if data.years.length > 1} @@ -37,7 +63,12 @@ {#each data.years as y (y.year)} - + pickYear(y.year)} + title={selectedYear === y.year ? 'Show all years' : `Show only ${y.year}`} + > {y.year} {y.count} {fmtDistance(y.distance_m)} @@ -51,14 +82,18 @@ {/if} {/if} -{#if data.activities.length === 0} +{#if filtered.length === 0 && data.activities.length > 0} +
+

No activities in {selectedYear}.

+
+{:else if data.activities.length === 0}

No activities yet.

Upload a GPX file
{:else}
- {#each data.activities as activity (activity.id)} + {#each filtered as activity (activity.id)} {/each}
@@ -98,6 +133,29 @@ .year-table td:first-child { font-weight: 600; } + .year-row { + cursor: pointer; + } + .year-row:hover td { + background: var(--wash); + } + .year-row.on td { + background: var(--accent-wash); + } + .year-row.on td:first-child { + color: var(--accent-strong); + } + .clear-year { + border: none; + background: none; + color: var(--accent-strong); + font: inherit; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + padding: 0; + text-decoration: underline; + } .list :global(.item:not(:last-child)) { border-bottom: 1px solid var(--border); } diff --git a/src/routes/activities/[id]/+page.server.ts b/src/routes/activities/[id]/+page.server.ts index 58a2a76..d901ea2 100644 --- a/src/routes/activities/[id]/+page.server.ts +++ b/src/routes/activities/[id]/+page.server.ts @@ -1,5 +1,5 @@ -import { error, redirect } from '@sveltejs/kit'; -import { deleteActivity, getActivity, reachedPeaks } from '$lib/server/db'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { db, deleteActivity, getActivity, reachedPeaks } from '$lib/server/db'; import { haversine } from '$lib/server/gpx'; import type { Actions, PageServerLoad } from './$types'; @@ -16,14 +16,17 @@ export const load: PageServerLoad = ({ params, locals }) => { const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]); - // cumulative-distance profiles for the elevation and speed charts + // cumulative-distance profiles for the elevation and speed charts, + // plus per-point distance so chart hover can find the map position const profile: { d: number; ele: number }[] = []; const timed: { d: number; t: number }[] = []; + const trackD: number[] = []; let dist = 0; for (let i = 0; i < points.length; i++) { if (i > 0) { dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon); } + trackD.push(dist); if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! }); if (points[i].t !== null) timed.push({ d: dist, t: points[i].t! }); } @@ -34,6 +37,7 @@ export const load: PageServerLoad = ({ params, locals }) => { activity: { ...activity, points: undefined }, canDelete: locals.user?.id === activity.user_id, latlngs, + trackD, profile, timed: timed.length > 2 ? timed : [], bagged: bagged.map((p) => ({ @@ -52,5 +56,20 @@ export const actions: Actions = { if (!locals.user) error(401, 'Not signed in'); deleteActivity(Number(params.id), locals.user.id); redirect(303, '/activities'); + }, + update: async ({ params, locals, request }) => { + if (!locals.user) error(401, 'Not signed in'); + const form = await request.formData(); + const name = String(form.get('name') ?? '').trim().slice(0, 120); + const type = String(form.get('type') ?? '').trim().toLowerCase().slice(0, 40); + if (!name) return fail(400, { error: 'Name cannot be empty.' }); + if (!type) return fail(400, { error: 'Type cannot be empty.' }); + db.prepare('UPDATE activities SET name = ?, type = ? WHERE id = ? AND user_id = ?').run( + name, + type, + Number(params.id), + locals.user.id + ); + return { updated: true }; } }; diff --git a/src/routes/activities/[id]/+page.svelte b/src/routes/activities/[id]/+page.svelte index 972c306..6e92c05 100644 --- a/src/routes/activities/[id]/+page.svelte +++ b/src/routes/activities/[id]/+page.svelte @@ -7,13 +7,44 @@ import { mapState } from '$lib/map-state.svelte'; import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format'; - let { data } = $props(); + 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); + + // 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]; + } + + function chartHover(d: number | null) { + 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, 14.5); + } + // the persistent map keeps the overview visible, dims the rest, // and animates toward this activity $effect(() => { @@ -40,14 +71,56 @@ ← Overview
-
-

{a.name}

-

- {a.username} · {fmtDate(a.date)} · - {a.type} -

-
- {#if data.canDelete} + {#if editing} +
{ + saving = true; + return async ({ update, result }) => { + saving = false; + if (result.type === 'success') editing = false; + await update(); + }; + }} + > + + + + {#each ['hike', 'run', 'ride', 'ski', 'climbing', 'walk', 'snowshoe'] as t (t)} + + {/each} + + + + {#if form?.error}{form.error}{/if} +
+ {:else} +
+

+ {a.name} + {#if data.canDelete} + + {/if} +

+

+ {a.username} · {fmtDate(a.date)} · + {a.type} +

+
+ {/if} + {#if data.canDelete && !editing}
1}

Elevation profile

- +
Low {fmtElevation(a.elev_min_m)} High {fmtElevation(a.elev_max_m)} @@ -99,7 +172,7 @@ {#if data.timed.length > 2}

Speed

- +
{/if} @@ -125,6 +198,52 @@ 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; }