diff --git a/src/app.css b/src/app.css index 6073141..c880ec8 100644 --- a/src/app.css +++ b/src/app.css @@ -99,6 +99,11 @@ box-sizing: border-box; } +html { + /* reserve the scrollbar gutter so page changes never shift content sideways */ + scrollbar-gutter: stable; +} + /* page transitions: gentle simultaneous cross-fade for content; the map morphs separately; topbar and footer are pinned and never fade */ @media not (prefers-reduced-motion: reduce) { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 70eccc4..18f9cc4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -9,7 +9,9 @@ function isPublic(path: string): boolean { AUTH_PATHS.has(path) || /^\/activities\/\d+$/.test(path) || path.startsWith('/users/') || - path.startsWith('/avatars/') + path.startsWith('/avatars/') || + path === '/segments' || + /^\/segments\/\d+$/.test(path) ); } diff --git a/src/lib/format.ts b/src/lib/format.ts index 8a6a7fe..dd975dd 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -29,3 +29,13 @@ export function fmtDate(iso: string | null | undefined): string { export function fmtSpeed(mPerS: number): string { return `${(mPerS * 3.6).toFixed(1)} km/h`; } + +/** mm:ss (or h:mm:ss) precision for segment efforts */ +export function fmtElapsed(s: number): string { + const total = Math.round(s); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const sec = total % 60; + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; + return `${m}:${String(sec).padStart(2, '0')}`; +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index cf2becb..1551b41 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -66,6 +66,28 @@ db.exec(` ); CREATE INDEX IF NOT EXISTS idx_peaks_lat_zoom ON peaks(minzoom, lat, lon); + CREATE TABLE IF NOT EXISTS segments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + creator_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + points TEXT NOT NULL, + bounds TEXT NOT NULL, + distance_m REAL NOT NULL, + elev_gain_m REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS segment_efforts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + segment_id INTEGER NOT NULL REFERENCES segments(id) ON DELETE CASCADE, + activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + elapsed_s REAL, + date TEXT, + UNIQUE (segment_id, activity_id) + ); + CREATE INDEX IF NOT EXISTS idx_efforts_segment ON segment_efforts(segment_id); + CREATE TABLE IF NOT EXISTS ascents ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -366,6 +388,78 @@ export function recordAscent( return result.changes > 0; } +export interface SegmentRow { + id: number; + name: string; + creator_id: number | null; + points: string; + bounds: string; + distance_m: number; + elev_gain_m: number; + created_at: string; +} + +export function listSegments(): (Omit & { + efforts: number; + athletes: number; + best_s: number | null; +})[] { + return db + .prepare( + `SELECT s.id, s.name, s.creator_id, s.bounds, s.distance_m, s.elev_gain_m, s.created_at, + count(e.id) efforts, + count(DISTINCT e.user_id) athletes, + min(e.elapsed_s) best_s + FROM segments s LEFT JOIN segment_efforts e ON e.segment_id = s.id + GROUP BY s.id ORDER BY efforts DESC, s.id DESC` + ) + .all() as (Omit & { + efforts: number; + athletes: number; + best_s: number | null; + })[]; +} + +export function getSegment(id: number): SegmentRow | undefined { + return db.prepare('SELECT * FROM segments WHERE id = ?').get(id) as SegmentRow | undefined; +} + +export function segmentLeaderboard(segmentId: number): { + user_id: number; + username: string; + best_s: number; + attempts: number; + date: string | null; +}[] { + return db + .prepare( + `SELECT e.user_id, u.username, min(e.elapsed_s) best_s, count(*) attempts, + (SELECT date FROM segment_efforts b + WHERE b.segment_id = e.segment_id AND b.user_id = e.user_id + ORDER BY b.elapsed_s ASC LIMIT 1) date + FROM segment_efforts e JOIN users u ON u.id = e.user_id + WHERE e.segment_id = ? AND e.elapsed_s IS NOT NULL + GROUP BY e.user_id ORDER BY best_s ASC` + ) + .all(segmentId) as { + user_id: number; + username: string; + best_s: number; + attempts: number; + date: string | null; + }[]; +} + +export function segmentEffortsForActivity(activityId: number): { segment_id: number; name: string; elapsed_s: number | null }[] { + return db + .prepare( + `SELECT e.segment_id, s.name, e.elapsed_s + FROM segment_efforts e JOIN segments s ON s.id = e.segment_id + WHERE e.activity_id = ? ORDER BY s.name` + ) + .all(activityId) as { segment_id: number; name: string; elapsed_s: number | null }[]; +} + export function reachedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] { return db .prepare( diff --git a/src/lib/server/segments.ts b/src/lib/server/segments.ts new file mode 100644 index 0000000..79d1dd1 --- /dev/null +++ b/src/lib/server/segments.ts @@ -0,0 +1,330 @@ +import { db, getSegment, type SegmentRow } from './db'; +import { haversine } from './gpx'; + +export interface TrackPt { + lat: number; + lon: number; + ele: number | null; + t: number | null; +} + +const CORRIDOR_M = 45; // how far an activity may stray from the segment line +const MIN_SEGMENT_M = 100; +const SAMPLE_M = 30; // segment checkpoints spacing for matching + +function cumDist(points: { lat: number; lon: number }[]): number[] { + const d: number[] = [0]; + for (let i = 1; i < points.length; i++) { + d.push(d[i - 1] + haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)); + } + return d; +} + +/** thin a segment line to ~SAMPLE_M checkpoints, keeping first and last */ +function checkpoints(points: T[]): T[] { + const d = cumDist(points); + const out: T[] = [points[0]]; + let next = SAMPLE_M; + for (let i = 1; i < points.length - 1; i++) { + if (d[i] >= next) { + out.push(points[i]); + next = d[i] + SAMPLE_M; + } + } + out.push(points[points.length - 1]); + return out; +} + +/** + * Try to find the segment inside an activity: the activity must pass the + * segment start, then every checkpoint in order within the corridor. + * Returns the best (fastest) timed effort, an untimed match, or null. + */ +export function computeEffort( + segPoints: { lat: number; lon: number }[], + actPoints: TrackPt[] +): { elapsed_s: number | null } | null { + if (segPoints.length < 2 || actPoints.length < 2) return null; + const cps = checkpoints(segPoints); + const start = cps[0]; + + // candidate entries: activity points near the segment start (spaced apart to + // catch repeat laps without re-testing every neighbouring point) + const candidates: number[] = []; + for (let i = 0; i < actPoints.length; i++) { + if (haversine(actPoints[i].lat, actPoints[i].lon, start.lat, start.lon) <= CORRIDOR_M) { + if (candidates.length === 0 || i - candidates[candidates.length - 1] > 15) candidates.push(i); + } + } + + let matched = false; + let best: number | null = null; + for (const s of candidates) { + let j = s; + let ok = true; + for (let k = 1; k < cps.length; k++) { + while ( + j < actPoints.length && + haversine(actPoints[j].lat, actPoints[j].lon, cps[k].lat, cps[k].lon) > CORRIDOR_M + ) { + j++; + } + if (j >= actPoints.length) { + ok = false; + break; + } + } + if (!ok) break; // ran off the end of the activity; later candidates would too + matched = true; + const t0 = actPoints[s].t; + const t1 = actPoints[j].t; + if (t0 !== null && t1 !== null && t1 > t0) { + best = best === null ? t1 - t0 : Math.min(best, t1 - t0); + } + } + return matched ? { elapsed_s: best } : null; +} + +function segmentBounds(points: { lat: number; lon: number }[]): string { + let minLat = Infinity, + minLon = Infinity, + maxLat = -Infinity, + maxLon = -Infinity; + for (const p of points) { + minLat = Math.min(minLat, p.lat); + maxLat = Math.max(maxLat, p.lat); + minLon = Math.min(minLon, p.lon); + maxLon = Math.max(maxLon, p.lon); + } + return JSON.stringify([ + [minLat, minLon], + [maxLat, maxLon] + ]); +} + +function gainOf(points: TrackPt[]): number { + let gain = 0; + let ref: number | null = null; + for (const p of points) { + if (p.ele === null) continue; + if (ref === null) ref = p.ele; + const diff = p.ele - ref; + if (diff >= 3) { + gain += diff; + ref = p.ele; + } else if (diff <= -3) { + ref = p.ele; + } + } + return gain; +} + +const insertEffort = db.prepare( + `INSERT INTO segment_efforts (segment_id, activity_id, user_id, elapsed_s, date) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (segment_id, activity_id) DO NOTHING` +); + +function activityRows(): { id: number; user_id: number; date: string | null; points: string; bounds: string }[] { + return db + .prepare('SELECT id, user_id, date, points, bounds FROM activities') + .all() as { id: number; user_id: number; date: string | null; points: string; bounds: string }[]; +} + +function boundsOverlap(a: string, b: string, marginDeg = 0.002): boolean { + const [[aMinLat, aMinLon], [aMaxLat, aMaxLon]] = JSON.parse(a); + const [[bMinLat, bMinLon], [bMaxLat, bMaxLon]] = JSON.parse(b); + return ( + aMinLat - marginDeg <= bMaxLat && + aMaxLat + marginDeg >= bMinLat && + aMinLon - marginDeg <= bMaxLon && + aMaxLon + marginDeg >= bMinLon + ); +} + +/** Create a segment from a distance slice of an activity and match everyone against it. */ +export function createSegmentFromSlice( + name: string, + creatorId: number, + activityId: number, + startD: number, + endD: number +): { id: number } | { error: string } { + const activity = db + .prepare('SELECT points FROM activities WHERE id = ?') + .get(activityId) as { points: string } | undefined; + if (!activity) return { error: 'Activity not found.' }; + + const points = JSON.parse(activity.points) as TrackPt[]; + const d = cumDist(points); + const from = Math.min(startD, endD); + const to = Math.max(startD, endD); + const slice = points.filter((_, i) => d[i] >= from && d[i] <= to); + if (slice.length < 2 || to - from < MIN_SEGMENT_M) { + return { error: `A segment must be at least ${MIN_SEGMENT_M} m long.` }; + } + + const result = db + .prepare( + `INSERT INTO segments (name, creator_id, points, bounds, distance_m, elev_gain_m) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + name, + creatorId, + JSON.stringify(slice.map((p) => ({ lat: p.lat, lon: p.lon, ele: p.ele, t: null }))), + segmentBounds(slice), + to - from, + gainOf(slice) + ); + const segmentId = Number(result.lastInsertRowid); + matchSegmentToAllActivities(segmentId); + return { id: segmentId }; +} + +/** Scan every activity for efforts on one (new) segment. */ +export function matchSegmentToAllActivities(segmentId: number): number { + const segment = getSegment(segmentId); + if (!segment) return 0; + const segPoints = JSON.parse(segment.points) as TrackPt[]; + let found = 0; + for (const activity of activityRows()) { + if (!boundsOverlap(segment.bounds, activity.bounds)) continue; + const effort = computeEffort(segPoints, JSON.parse(activity.points) as TrackPt[]); + if (effort) { + insertEffort.run(segmentId, activity.id, activity.user_id, effort.elapsed_s, activity.date); + found++; + } + } + return found; +} + +/** Match one (new) activity against every segment. */ +export function matchActivityToSegments(activityId: number): string[] { + const activity = db + .prepare('SELECT id, user_id, date, points, bounds FROM activities WHERE id = ?') + .get(activityId) as + | { id: number; user_id: number; date: string | null; points: string; bounds: string } + | undefined; + if (!activity) return []; + const actPoints = JSON.parse(activity.points) as TrackPt[]; + const matched: string[] = []; + for (const segment of db.prepare('SELECT * FROM segments').all() as SegmentRow[]) { + if (!boundsOverlap(segment.bounds, activity.bounds)) continue; + const effort = computeEffort(JSON.parse(segment.points) as TrackPt[], actPoints); + if (effort) { + insertEffort.run(segment.id, activity.id, activity.user_id, effort.elapsed_s, activity.date); + matched.push(segment.name); + } + } + return matched; +} + +// ---- suggestion mining: popular overlapping stretches -------------------- + +const CELL_DEG = 0.00028; // ~30 m grid +const MIN_ACTIVITIES = 5; // "more than 5x" -> stretches shared by > 5 activities +const MIN_STRETCH_M = 400; + +function cellKey(lat: number, lon: number): string { + return `${Math.round(lat / CELL_DEG)}:${Math.round(lon / CELL_DEG)}`; +} + +export interface SegmentSuggestion { + activity_id: number; + activity_name: string; + start_d: number; + end_d: number; + distance_m: number; + activities: number; +} + +/** + * Find stretches travelled by more than MIN_ACTIVITIES distinct activities + * that aren't already covered by an existing segment. + */ +export function suggestSegments(limit = 8): SegmentSuggestion[] { + const rows = db + .prepare('SELECT a.id, a.name, a.points FROM activities a') + .all() as { id: number; name: string; points: string }[]; + if (rows.length <= MIN_ACTIVITIES) return []; + + // pass 1: how many distinct activities touch each grid cell + const cellCounts = new Map(); + const parsed = rows.map((row) => { + const points = JSON.parse(row.points) as TrackPt[]; + const seen = new Set(); + for (const p of points) seen.add(cellKey(p.lat, p.lon)); + for (const key of seen) cellCounts.set(key, (cellCounts.get(key) ?? 0) + 1); + return { ...row, points }; + }); + + // cells already claimed by existing segments + const claimed = new Set(); + for (const segment of db.prepare('SELECT points FROM segments').all() as { points: string }[]) { + for (const p of JSON.parse(segment.points) as TrackPt[]) claimed.add(cellKey(p.lat, p.lon)); + } + + // pass 2: walk each activity, collect popular unclaimed runs + interface Candidate extends SegmentSuggestion { + cells: Set; + score: number; + } + const candidates: Candidate[] = []; + for (const activity of parsed) { + const d = cumDist(activity.points); + let runStart = -1; + let gap = 0; + let counts: number[] = []; + const flush = (endIdx: number) => { + if (runStart < 0) return; + const length = d[endIdx] - d[runStart]; + if (length >= MIN_STRETCH_M) { + const cells = new Set(); + for (let i = runStart; i <= endIdx; i++) { + cells.add(cellKey(activity.points[i].lat, activity.points[i].lon)); + } + const avg = counts.reduce((s, c) => s + c, 0) / counts.length; + candidates.push({ + activity_id: activity.id, + activity_name: activity.name, + start_d: d[runStart], + end_d: d[endIdx], + distance_m: length, + activities: Math.round(avg), + cells, + score: length * avg + }); + } + runStart = -1; + counts = []; + }; + for (let i = 0; i < activity.points.length; i++) { + const key = cellKey(activity.points[i].lat, activity.points[i].lon); + const count = cellCounts.get(key) ?? 0; + const popular = count > MIN_ACTIVITIES && !claimed.has(key); + if (popular) { + if (runStart < 0) runStart = i; + gap = 0; + counts.push(count); + } else if (runStart >= 0 && ++gap > 4) { + flush(i - gap); + } + } + flush(activity.points.length - 1); + } + + // greedy pick by score, skipping candidates that mostly repeat an accepted one + candidates.sort((a, b) => b.score - a.score); + const accepted: Candidate[] = []; + const used = new Set(); + for (const candidate of candidates) { + if (accepted.length >= limit) break; + let overlap = 0; + for (const cell of candidate.cells) if (used.has(cell)) overlap++; + if (overlap / candidate.cells.size > 0.4) continue; + for (const cell of candidate.cells) used.add(cell); + accepted.push(candidate); + } + return accepted.map(({ cells: _cells, score: _score, ...suggestion }) => suggestion); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 99c4804..0caf84a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -70,6 +70,7 @@ const links = [ { href: '/', label: 'Dashboard' }, { href: '/activities', label: 'Activities' }, + { href: '/segments', label: 'Segments' }, { href: '/peaks', label: 'Peaks' }, { href: '/upload', label: 'Upload' } ]; diff --git a/src/routes/activities/[id]/+page.server.ts b/src/routes/activities/[id]/+page.server.ts index d901ea2..8de3391 100644 --- a/src/routes/activities/[id]/+page.server.ts +++ b/src/routes/activities/[id]/+page.server.ts @@ -1,5 +1,6 @@ import { error, fail, redirect } from '@sveltejs/kit'; -import { db, deleteActivity, getActivity, reachedPeaks } from '$lib/server/db'; +import { db, deleteActivity, getActivity, reachedPeaks, segmentEffortsForActivity } from '$lib/server/db'; +import { createSegmentFromSlice } from '$lib/server/segments'; import { haversine } from '$lib/server/gpx'; import type { Actions, PageServerLoad } from './$types'; @@ -36,6 +37,8 @@ export const load: PageServerLoad = ({ params, locals }) => { return { activity: { ...activity, points: undefined }, canDelete: locals.user?.id === activity.user_id, + canCreateSegment: !!locals.user, + segmentEfforts: segmentEffortsForActivity(activity.id), latlngs, trackD, profile, @@ -57,6 +60,23 @@ export const actions: Actions = { deleteActivity(Number(params.id), locals.user.id); redirect(303, '/activities'); }, + segment: 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, 80); + const startD = Number(form.get('start_d')); + const endD = Number(form.get('end_d')); + if (!name) return fail(400, { error: 'Give the segment a name.' }); + const result = createSegmentFromSlice( + name, + locals.user.id, + Number(params.id), + startD, + endD + ); + if ('error' in result) return fail(400, { error: result.error }); + redirect(303, `/segments/${result.id}`); + }, update: async ({ params, locals, request }) => { if (!locals.user) error(401, 'Not signed in'); const form = await request.formData(); diff --git a/src/routes/activities/[id]/+page.svelte b/src/routes/activities/[id]/+page.svelte index c34e983..98f838f 100644 --- a/src/routes/activities/[id]/+page.svelte +++ b/src/routes/activities/[id]/+page.svelte @@ -9,6 +9,7 @@ fmtDate, fmtDistance, fmtDuration, + fmtElapsed, fmtElevation, fmtSpeed, } from "$lib/format"; @@ -23,6 +24,14 @@ 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; @@ -192,6 +201,65 @@ {/if} +{#if data.segmentEfforts.length > 0} +
+ Segments on this activity: + {#each data.segmentEfforts as effort, i (effort.segment_id)}{i > 0 + ? " · " + : " "}{effort.name}{#if effort.elapsed_s !== null} ({fmtElapsed( + effort.elapsed_s, + )}){/if}{/each} +
+{/if} + +{#if data.canCreateSegment} +
+ {#if segmenting} +
+
+ + + Length: {fmtDistance(Math.abs(segEnd - segStart))} +
+
+ + + +
+ {#if form?.error}

{form.error}

{/if} +
+ {:else} + + {/if} +
+{/if} + {#if data.profile.length > 1} @@ -293,6 +361,73 @@ .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); diff --git a/src/routes/segments/+page.server.ts b/src/routes/segments/+page.server.ts new file mode 100644 index 0000000..193db86 --- /dev/null +++ b/src/routes/segments/+page.server.ts @@ -0,0 +1,30 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { listSegments } from '$lib/server/db'; +import { createSegmentFromSlice, suggestSegments } from '$lib/server/segments'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ locals }) => { + return { + segments: listSegments(), + suggestions: locals.user ? suggestSegments() : [] + }; +}; + +export const actions: Actions = { + // create a segment from a mined suggestion + create: async ({ request, locals }) => { + if (!locals.user) error(401, 'Not signed in'); + const form = await request.formData(); + const name = String(form.get('name') ?? '').trim().slice(0, 80); + const activityId = Number(form.get('activity_id')); + const startD = Number(form.get('start_d')); + const endD = Number(form.get('end_d')); + if (!name) return fail(400, { error: 'Give the segment a name.' }); + if (!Number.isFinite(activityId) || !Number.isFinite(startD) || !Number.isFinite(endD)) { + return fail(400, { error: 'Invalid segment range.' }); + } + const result = createSegmentFromSlice(name, locals.user.id, activityId, startD, endD); + if ('error' in result) return fail(400, { error: result.error }); + redirect(303, `/segments/${result.id}`); + } +}; diff --git a/src/routes/segments/+page.svelte b/src/routes/segments/+page.svelte new file mode 100644 index 0000000..ac334ac --- /dev/null +++ b/src/routes/segments/+page.svelte @@ -0,0 +1,149 @@ + + + + Segments · Streba + + +

Segments

+

+ Community stretches everyone competes on - created from activities, timed automatically for + every upload. +

+ +{#if data.segments.length === 0} +
+ No segments yet. Create one from an activity page, or pick a suggested stretch below. +
+{:else} + +{/if} + +{#if data.suggestions.length > 0} +

Suggested segments

+

+ Stretches covered by more than 5 activities that aren't segments yet. +

+ {#if form?.error}

{form.error}

{/if} +
+ {#each data.suggestions as suggestion, i (i)} +
+
+ + {fmtDistance(suggestion.distance_m)} stretch + {suggestion.activities} activities + + + from “{suggestion.activity_name}”, km {(suggestion.start_d / 1000).toFixed(1)}–{( + suggestion.end_d / 1000 + ).toFixed(1)} + +
+ {#if naming === i} +
+ + + + + + + +
+ {:else} + + {/if} +
+ {/each} +
+{/if} + + diff --git a/src/routes/segments/[id]/+page.server.ts b/src/routes/segments/[id]/+page.server.ts new file mode 100644 index 0000000..be49b0f --- /dev/null +++ b/src/routes/segments/[id]/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { getSegment, segmentLeaderboard } from '$lib/server/db'; +import type { PageServerLoad } from './$types'; +import type { TrackPt } from '$lib/server/segments'; + +export const load: PageServerLoad = ({ params, locals }) => { + const segment = getSegment(Number(params.id)); + if (!segment) error(404, 'Segment not found'); + + const points = JSON.parse(segment.points) as TrackPt[]; + return { + segment: { ...segment, points: undefined }, + latlngs: points.map((p) => [p.lat, p.lon] as [number, number]), + leaderboard: segmentLeaderboard(segment.id), + myUserId: locals.user?.id ?? null + }; +}; diff --git a/src/routes/segments/[id]/+page.svelte b/src/routes/segments/[id]/+page.svelte new file mode 100644 index 0000000..4cb0d51 --- /dev/null +++ b/src/routes/segments/[id]/+page.svelte @@ -0,0 +1,128 @@ + + + + {s.name} · Streba + + +← Segments + +

{s.name}

+

Segment · created {fmtDate(s.created_at.slice(0, 10))}

+ +
+ + + +
+ +

Leaderboard

+{#if data.leaderboard.length === 0} +
No timed efforts yet - ride or run it with a GPS track.
+{:else} +
+ + + + + + {#each data.leaderboard as row, i (row.user_id)} + + + + + + + + {/each} + +
#AthleteBest timeAttemptsDate
{i + 1}{row.username}{fmtElapsed(row.best_s)}{row.attempts}{row.date ? fmtDate(row.date) : '–'}
+
+{/if} + + diff --git a/src/routes/upload/+page.server.ts b/src/routes/upload/+page.server.ts index ed71155..2a6b4c7 100644 --- a/src/routes/upload/+page.server.ts +++ b/src/routes/upload/+page.server.ts @@ -1,5 +1,6 @@ import { fail } from '@sveltejs/kit'; import { db, peaksNearBounds, recordAscent } from '$lib/server/db'; +import { matchActivityToSegments } from '$lib/server/segments'; import { matchPeaks, parseGpx } from '$lib/server/gpx'; import type { Actions } from './$types'; @@ -70,7 +71,7 @@ export const actions: Actions = { const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0); if (files.length === 0) return fail(400, { error: 'No GPX files selected.' }); - const uploaded: { id: number; name: string; newPeaks: string[] }[] = []; + const uploaded: { id: number; name: string; newPeaks: string[]; segments: string[] }[] = []; const errors: string[] = []; const insert = db.prepare(` @@ -122,7 +123,8 @@ export const actions: Actions = { if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name); } - uploaded.push({ id: activityId, name, newPeaks }); + const segments = matchActivityToSegments(activityId); + uploaded.push({ id: activityId, name, newPeaks, segments }); } catch (err) { errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`); } diff --git a/src/routes/upload/+page.svelte b/src/routes/upload/+page.svelte index 6798a88..f0c9ce4 100644 --- a/src/routes/upload/+page.svelte +++ b/src/routes/upload/+page.svelte @@ -91,6 +91,9 @@ {#if item.newPeaks.length > 0} ⛰ Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}! {/if} + {#if item.segments.length > 0} + ⏱ Segment{item.segments.length > 1 ? 's' : ''} matched: {item.segments.join(', ')} + {/if}

{/each} {#each form.errors as message (message)}