diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 9945ffe..4bf30d1 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -424,33 +424,44 @@ export function getSegment(id: number): SegmentRow | undefined { return db.prepare('SELECT * FROM segments WHERE id = ?').get(id) as SegmentRow | undefined; } +/** Per-user bests split by activity type - a run doesn't compete with a ride. */ export function segmentLeaderboard(segmentId: number): { user_id: number; username: string; + type: 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 + `SELECT e.user_id, u.username, a.type, min(e.elapsed_s) best_s, count(*) attempts, + (SELECT b.date FROM segment_efforts b + JOIN activities ab ON ab.id = b.activity_id WHERE b.segment_id = e.segment_id AND b.user_id = e.user_id + AND ab.type = a.type AND b.elapsed_s IS NOT NULL ORDER BY b.elapsed_s ASC LIMIT 1) date - FROM segment_efforts e JOIN users u ON u.id = e.user_id + FROM segment_efforts e + JOIN users u ON u.id = e.user_id + JOIN activities a ON a.id = e.activity_id WHERE e.segment_id = ? AND e.elapsed_s IS NOT NULL - GROUP BY e.user_id ORDER BY best_s ASC` + GROUP BY e.user_id, a.type ORDER BY best_s ASC` ) .all(segmentId) as { user_id: number; username: string; + type: string; best_s: number; attempts: number; date: string | null; }[]; } -/** All segments a user has completed, with their best time, attempts, and rank. */ +/** + * All segments a user has completed, with their best time, attempts, and + * rank. Rank is computed within the activity type of the user's best effort, + * since leaderboards are split by type. + */ export function userSegments(userId: number): { id: number; name: string; @@ -458,20 +469,12 @@ export function userSegments(userId: number): { elev_gain_m: number; attempts: number; best_s: number | null; + best_type: string | null; rank: number | null; }[] { - return db + const base = db .prepare( - `SELECT s.id, s.name, s.distance_m, s.elev_gain_m, - count(e.id) attempts, - min(e.elapsed_s) best_s, - CASE WHEN min(e.elapsed_s) IS NULL THEN NULL ELSE ( - SELECT count(*) + 1 FROM ( - SELECT min(e2.elapsed_s) b FROM segment_efforts e2 - WHERE e2.segment_id = s.id AND e2.elapsed_s IS NOT NULL AND e2.user_id != @userId - GROUP BY e2.user_id - ) others WHERE others.b < min(e.elapsed_s) - ) END rank + `SELECT s.id, s.name, s.distance_m, s.elev_gain_m, count(e.id) attempts FROM segments s JOIN segment_efforts e ON e.segment_id = s.id AND e.user_id = @userId GROUP BY s.id ORDER BY attempts DESC, s.name` ) @@ -481,9 +484,37 @@ export function userSegments(userId: number): { distance_m: number; elev_gain_m: number; attempts: number; - best_s: number | null; - rank: number | null; }[]; + + const bestStmt = db.prepare( + `SELECT e.elapsed_s, a.type FROM segment_efforts e + JOIN activities a ON a.id = e.activity_id + WHERE e.segment_id = ? AND e.user_id = ? AND e.elapsed_s IS NOT NULL + ORDER BY e.elapsed_s ASC LIMIT 1` + ); + const rankStmt = db.prepare( + `SELECT count(*) + 1 n FROM ( + SELECT min(e2.elapsed_s) b FROM segment_efforts e2 + JOIN activities a2 ON a2.id = e2.activity_id + WHERE e2.segment_id = ? AND a2.type = ? AND e2.elapsed_s IS NOT NULL AND e2.user_id != ? + GROUP BY e2.user_id + ) others WHERE others.b < ?` + ); + + return base.map((segment) => { + const best = bestStmt.get(segment.id, userId) as + | { elapsed_s: number; type: string } + | undefined; + const rank = best + ? (rankStmt.get(segment.id, best.type, userId, best.elapsed_s) as { n: number }).n + : null; + return { + ...segment, + best_s: best?.elapsed_s ?? null, + best_type: best?.type ?? null, + rank + }; + }); } export function deleteSegment(id: number, userId: number): boolean { @@ -502,12 +533,13 @@ export function segmentAllEfforts(segmentId: number): { username: string; activity_id: number; activity_name: string; + type: string; elapsed_s: number | null; date: string | null; }[] { return db .prepare( - `SELECT e.id, e.user_id, u.username, e.activity_id, a.name activity_name, + `SELECT e.id, e.user_id, u.username, e.activity_id, a.name activity_name, a.type, e.elapsed_s, e.date FROM segment_efforts e JOIN users u ON u.id = e.user_id @@ -521,6 +553,7 @@ export function segmentAllEfforts(segmentId: number): { username: string; activity_id: number; activity_name: string; + type: string; elapsed_s: number | null; date: string | null; }[]; diff --git a/src/routes/segments/[id]/+page.svelte b/src/routes/segments/[id]/+page.svelte index e98a492..618c572 100644 --- a/src/routes/segments/[id]/+page.svelte +++ b/src/routes/segments/[id]/+page.svelte @@ -3,11 +3,25 @@ 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(); + 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(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(() => { @@ -53,14 +67,29 @@ -

Leaderboard

-{#if data.leaderboard.length === 0} +{#if types.length > 1} +
+ {#each types as type (type)} + + {/each} +
+{/if} + +

Leaderboard{selectedType ? ` · ${selectedType}` : ''}

+{#if board.length === 0}
No timed efforts yet - ride or run it with a GPS track.
{:else}
@@ -69,7 +98,7 @@ #AthleteBest timeAttemptsDate - {#each data.leaderboard as row, i (row.user_id)} + {#each board as row, i (row.user_id + row.type)} {i + 1} {row.username} @@ -83,15 +112,15 @@
{/if} -{#if data.efforts.length > 0} -

All efforts ({data.efforts.length})

+{#if efforts.length > 0} +

All efforts{selectedType ? ` · ${selectedType}` : ''} ({efforts.length})

- {#each data.efforts as effort (effort.id)} + {#each efforts as effort (effort.id)} @@ -131,6 +160,32 @@ 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; } diff --git a/src/routes/users/[username]/+page.svelte b/src/routes/users/[username]/+page.svelte index 8823a90..8210a66 100644 --- a/src/routes/users/[username]/+page.svelte +++ b/src/routes/users/[username]/+page.svelte @@ -54,7 +54,7 @@ {fmtDistance(segment.distance_m)} · {segment.attempts} {segment.attempts === 1 ? 'attempt' : 'attempts'} {#if segment.best_s !== null} - · best {fmtElapsed(segment.best_s)} + · best {fmtElapsed(segment.best_s)}{#if segment.best_type} ({segment.best_type}){/if} {#if segment.rank !== null}· #{segment.rank}{/if} {/if}
AthleteTimeDateActivity
{effort.username} {effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : '–'}