split segment efforts and leaderboards by activity type
This commit is contained in:
+52
-19
@@ -424,33 +424,44 @@ export function getSegment(id: number): SegmentRow | undefined {
|
|||||||
return db.prepare('SELECT * FROM segments WHERE id = ?').get(id) as 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): {
|
export function segmentLeaderboard(segmentId: number): {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
type: string;
|
||||||
best_s: number;
|
best_s: number;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
}[] {
|
}[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT e.user_id, u.username, min(e.elapsed_s) best_s, count(*) attempts,
|
`SELECT e.user_id, u.username, a.type, min(e.elapsed_s) best_s, count(*) attempts,
|
||||||
(SELECT date FROM segment_efforts b
|
(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
|
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
|
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
|
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 {
|
.all(segmentId) as {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
type: string;
|
||||||
best_s: number;
|
best_s: number;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
date: string | null;
|
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): {
|
export function userSegments(userId: number): {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -458,20 +469,12 @@ export function userSegments(userId: number): {
|
|||||||
elev_gain_m: number;
|
elev_gain_m: number;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
best_s: number | null;
|
best_s: number | null;
|
||||||
|
best_type: string | null;
|
||||||
rank: number | null;
|
rank: number | null;
|
||||||
}[] {
|
}[] {
|
||||||
return db
|
const base = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT s.id, s.name, s.distance_m, s.elev_gain_m,
|
`SELECT s.id, s.name, s.distance_m, s.elev_gain_m, count(e.id) attempts
|
||||||
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
|
|
||||||
FROM segments s JOIN segment_efforts e ON e.segment_id = s.id AND e.user_id = @userId
|
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`
|
GROUP BY s.id ORDER BY attempts DESC, s.name`
|
||||||
)
|
)
|
||||||
@@ -481,9 +484,37 @@ export function userSegments(userId: number): {
|
|||||||
distance_m: number;
|
distance_m: number;
|
||||||
elev_gain_m: number;
|
elev_gain_m: number;
|
||||||
attempts: 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 {
|
export function deleteSegment(id: number, userId: number): boolean {
|
||||||
@@ -502,12 +533,13 @@ export function segmentAllEfforts(segmentId: number): {
|
|||||||
username: string;
|
username: string;
|
||||||
activity_id: number;
|
activity_id: number;
|
||||||
activity_name: string;
|
activity_name: string;
|
||||||
|
type: string;
|
||||||
elapsed_s: number | null;
|
elapsed_s: number | null;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
}[] {
|
}[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.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
|
e.elapsed_s, e.date
|
||||||
FROM segment_efforts e
|
FROM segment_efforts e
|
||||||
JOIN users u ON u.id = e.user_id
|
JOIN users u ON u.id = e.user_id
|
||||||
@@ -521,6 +553,7 @@ export function segmentAllEfforts(segmentId: number): {
|
|||||||
username: string;
|
username: string;
|
||||||
activity_id: number;
|
activity_id: number;
|
||||||
activity_name: string;
|
activity_name: string;
|
||||||
|
type: string;
|
||||||
elapsed_s: number | null;
|
elapsed_s: number | null;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
|||||||
@@ -3,11 +3,25 @@
|
|||||||
import MapSlot from '$lib/components/MapSlot.svelte';
|
import MapSlot from '$lib/components/MapSlot.svelte';
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
import { mapState } from '$lib/map-state.svelte';
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
|
import { typeSlot } from '$lib/activity-colors';
|
||||||
import { fmtDate, fmtDistance, fmtElapsed, fmtElevation } from '$lib/format';
|
import { fmtDate, fmtDistance, fmtElapsed, fmtElevation } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const s = $derived(data.segment);
|
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
|
// show the segment on the persistent map: synthetic negative id so it can
|
||||||
// be highlighted without colliding with activity ids
|
// be highlighted without colliding with activity ids
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -53,14 +67,29 @@
|
|||||||
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
|
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
|
||||||
<StatTile label="Ascent" value={fmtElevation(s.elev_gain_m)} />
|
<StatTile label="Ascent" value={fmtElevation(s.elev_gain_m)} />
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Record"
|
label={selectedType ? `Record (${selectedType})` : 'Record'}
|
||||||
value={data.leaderboard[0] ? fmtElapsed(data.leaderboard[0].best_s) : '–'}
|
value={board[0] ? fmtElapsed(board[0].best_s) : '–'}
|
||||||
detail={data.leaderboard[0]?.username ?? 'no timed efforts yet'}
|
detail={board[0]?.username ?? 'no timed efforts yet'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Leaderboard</h2>
|
{#if types.length > 1}
|
||||||
{#if data.leaderboard.length === 0}
|
<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>
|
<div class="card empty">No timed efforts yet - ride or run it with a GPS track.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card table-wrap">
|
<div class="card table-wrap">
|
||||||
@@ -69,7 +98,7 @@
|
|||||||
<tr><th>#</th><th>Athlete</th><th>Best time</th><th>Attempts</th><th>Date</th></tr>
|
<tr><th>#</th><th>Athlete</th><th>Best time</th><th>Attempts</th><th>Date</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.leaderboard as row, i (row.user_id)}
|
{#each board as row, i (row.user_id + row.type)}
|
||||||
<tr class:me={row.user_id === data.myUserId}>
|
<tr class:me={row.user_id === data.myUserId}>
|
||||||
<td>{i + 1}</td>
|
<td>{i + 1}</td>
|
||||||
<td><a href="/users/{row.username}">{row.username}</a></td>
|
<td><a href="/users/{row.username}">{row.username}</a></td>
|
||||||
@@ -83,15 +112,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.efforts.length > 0}
|
{#if efforts.length > 0}
|
||||||
<h2>All efforts ({data.efforts.length})</h2>
|
<h2>All efforts{selectedType ? ` · ${selectedType}` : ''} ({efforts.length})</h2>
|
||||||
<div class="card table-wrap">
|
<div class="card table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Athlete</th><th>Time</th><th>Date</th><th>Activity</th></tr>
|
<tr><th>Athlete</th><th>Time</th><th>Date</th><th>Activity</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.efforts as effort (effort.id)}
|
{#each efforts as effort (effort.id)}
|
||||||
<tr class:me={effort.user_id === data.myUserId}>
|
<tr class:me={effort.user_id === data.myUserId}>
|
||||||
<td><a href="/users/{effort.username}">{effort.username}</a></td>
|
<td><a href="/users/{effort.username}">{effort.username}</a></td>
|
||||||
<td class="time">{effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : '–'}</td>
|
<td class="time">{effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : '–'}</td>
|
||||||
@@ -131,6 +160,32 @@
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
gap: 0.75rem;
|
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 {
|
.table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
{fmtDistance(segment.distance_m)} · {segment.attempts}
|
{fmtDistance(segment.distance_m)} · {segment.attempts}
|
||||||
{segment.attempts === 1 ? 'attempt' : 'attempts'}
|
{segment.attempts === 1 ? 'attempt' : 'attempts'}
|
||||||
{#if segment.best_s !== null}
|
{#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 segment.rank !== null}· #{segment.rank}{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user