segments with leaderboards, auto-matching, and popular-stretch suggestions
This commit is contained in:
@@ -99,6 +99,11 @@
|
|||||||
box-sizing: border-box;
|
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;
|
/* page transitions: gentle simultaneous cross-fade for content;
|
||||||
the map morphs separately; topbar and footer are pinned and never fade */
|
the map morphs separately; topbar and footer are pinned and never fade */
|
||||||
@media not (prefers-reduced-motion: reduce) {
|
@media not (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@ function isPublic(path: string): boolean {
|
|||||||
AUTH_PATHS.has(path) ||
|
AUTH_PATHS.has(path) ||
|
||||||
/^\/activities\/\d+$/.test(path) ||
|
/^\/activities\/\d+$/.test(path) ||
|
||||||
path.startsWith('/users/') ||
|
path.startsWith('/users/') ||
|
||||||
path.startsWith('/avatars/')
|
path.startsWith('/avatars/') ||
|
||||||
|
path === '/segments' ||
|
||||||
|
/^\/segments\/\d+$/.test(path)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,13 @@ export function fmtDate(iso: string | null | undefined): string {
|
|||||||
export function fmtSpeed(mPerS: number): string {
|
export function fmtSpeed(mPerS: number): string {
|
||||||
return `${(mPerS * 3.6).toFixed(1)} km/h`;
|
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')}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,28 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_peaks_lat_zoom ON peaks(minzoom, lat, lon);
|
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 (
|
CREATE TABLE IF NOT EXISTS ascents (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -366,6 +388,78 @@ export function recordAscent(
|
|||||||
return result.changes > 0;
|
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<SegmentRow, 'points'> & {
|
||||||
|
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<SegmentRow, 'points'> & {
|
||||||
|
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 })[] {
|
export function reachedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -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<T extends { lat: number; lon: number }>(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<string, number>();
|
||||||
|
const parsed = rows.map((row) => {
|
||||||
|
const points = JSON.parse(row.points) as TrackPt[];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>;
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
const links = [
|
const links = [
|
||||||
{ href: '/', label: 'Dashboard' },
|
{ href: '/', label: 'Dashboard' },
|
||||||
{ href: '/activities', label: 'Activities' },
|
{ href: '/activities', label: 'Activities' },
|
||||||
|
{ href: '/segments', label: 'Segments' },
|
||||||
{ href: '/peaks', label: 'Peaks' },
|
{ href: '/peaks', label: 'Peaks' },
|
||||||
{ href: '/upload', label: 'Upload' }
|
{ href: '/upload', label: 'Upload' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
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 { haversine } from '$lib/server/gpx';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ export const load: PageServerLoad = ({ params, locals }) => {
|
|||||||
return {
|
return {
|
||||||
activity: { ...activity, points: undefined },
|
activity: { ...activity, points: undefined },
|
||||||
canDelete: locals.user?.id === activity.user_id,
|
canDelete: locals.user?.id === activity.user_id,
|
||||||
|
canCreateSegment: !!locals.user,
|
||||||
|
segmentEfforts: segmentEffortsForActivity(activity.id),
|
||||||
latlngs,
|
latlngs,
|
||||||
trackD,
|
trackD,
|
||||||
profile,
|
profile,
|
||||||
@@ -57,6 +60,23 @@ export const actions: Actions = {
|
|||||||
deleteActivity(Number(params.id), locals.user.id);
|
deleteActivity(Number(params.id), locals.user.id);
|
||||||
redirect(303, '/activities');
|
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 }) => {
|
update: async ({ params, locals, request }) => {
|
||||||
if (!locals.user) error(401, 'Not signed in');
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
fmtDate,
|
fmtDate,
|
||||||
fmtDistance,
|
fmtDistance,
|
||||||
fmtDuration,
|
fmtDuration,
|
||||||
|
fmtElapsed,
|
||||||
fmtElevation,
|
fmtElevation,
|
||||||
fmtSpeed,
|
fmtSpeed,
|
||||||
} from "$lib/format";
|
} from "$lib/format";
|
||||||
@@ -23,6 +24,14 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let saving = $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
|
// chart hover -> marker on the map at the matching track position
|
||||||
function pointAt(d: number): [number, number] {
|
function pointAt(d: number): [number, number] {
|
||||||
const trackD = data.trackD;
|
const trackD = data.trackD;
|
||||||
@@ -192,6 +201,65 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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} ({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 />
|
<MapSlot />
|
||||||
|
|
||||||
{#if data.profile.length > 1}
|
{#if data.profile.length > 1}
|
||||||
@@ -293,6 +361,73 @@
|
|||||||
.type {
|
.type {
|
||||||
text-transform: capitalize;
|
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 {
|
.who {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { fmtDistance, fmtElevation } from '$lib/format';
|
||||||
|
import { fmtElapsed } from '$lib/format';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
let naming = $state<number | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Segments · Streba</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Segments</h1>
|
||||||
|
<p class="page-sub">
|
||||||
|
Community stretches everyone competes on - created from activities, timed automatically for
|
||||||
|
every upload.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.segments.length === 0}
|
||||||
|
<div class="card empty">
|
||||||
|
No segments yet. Create one from an activity page, or pick a suggested stretch below.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card list">
|
||||||
|
{#each data.segments as segment (segment.id)}
|
||||||
|
<a class="segment" href="/segments/{segment.id}">
|
||||||
|
<span class="s-name">{segment.name}</span>
|
||||||
|
<span class="s-meta">
|
||||||
|
{fmtDistance(segment.distance_m)} · {fmtElevation(segment.elev_gain_m)} ↑ ·
|
||||||
|
{segment.athletes}
|
||||||
|
{segment.athletes === 1 ? 'athlete' : 'athletes'} · {segment.efforts}
|
||||||
|
{segment.efforts === 1 ? 'effort' : 'efforts'}
|
||||||
|
{#if segment.best_s !== null}
|
||||||
|
· record {fmtElapsed(segment.best_s)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.suggestions.length > 0}
|
||||||
|
<h2>Suggested segments</h2>
|
||||||
|
<p class="page-sub">
|
||||||
|
Stretches covered by more than 5 activities that aren't segments yet.
|
||||||
|
</p>
|
||||||
|
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
||||||
|
<div class="card list">
|
||||||
|
{#each data.suggestions as suggestion, i (i)}
|
||||||
|
<div class="suggestion">
|
||||||
|
<div class="sg-info">
|
||||||
|
<span class="s-name">
|
||||||
|
{fmtDistance(suggestion.distance_m)} stretch
|
||||||
|
<span class="sg-count">{suggestion.activities} activities</span>
|
||||||
|
</span>
|
||||||
|
<span class="s-meta">
|
||||||
|
from “{suggestion.activity_name}”, km {(suggestion.start_d / 1000).toFixed(1)}–{(
|
||||||
|
suggestion.end_d / 1000
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if naming === i}
|
||||||
|
<form class="sg-form" method="POST" action="?/create" use:enhance>
|
||||||
|
<input type="hidden" name="activity_id" value={suggestion.activity_id} />
|
||||||
|
<input type="hidden" name="start_d" value={suggestion.start_d} />
|
||||||
|
<input type="hidden" name="end_d" value={suggestion.end_d} />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input name="name" placeholder="Segment name" required maxlength="80" autofocus />
|
||||||
|
<button class="btn" type="submit">Create</button>
|
||||||
|
<button class="btn ghost" type="button" onclick={() => (naming = null)}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button class="btn ghost" onclick={() => (naming = i)}>Make segment</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.list :global(> *:not(:last-child)),
|
||||||
|
.list > *:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.segment {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 120ms;
|
||||||
|
}
|
||||||
|
.segment:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.s-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.s-meta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sg-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sg-count {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.sg-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sg-form input[name='name'] {
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--critical);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MapSlot from '$lib/components/MapSlot.svelte';
|
||||||
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
|
import { fmtDate, fmtDistance, fmtElapsed, fmtElevation } from '$lib/format';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const s = $derived(data.segment);
|
||||||
|
|
||||||
|
// show the segment on the persistent map: synthetic negative id so it can
|
||||||
|
// be highlighted without colliding with activity ids
|
||||||
|
$effect(() => {
|
||||||
|
mapState.setDetailTrack({
|
||||||
|
id: -s.id,
|
||||||
|
name: s.name,
|
||||||
|
type: 'segment',
|
||||||
|
latlngs: data.latlngs
|
||||||
|
});
|
||||||
|
mapState.setHighlight(-s.id);
|
||||||
|
mapState.setFocus(JSON.parse(s.bounds));
|
||||||
|
return () => mapState.reset();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{s.name} · Streba</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<a class="back" href="/segments">← Segments</a>
|
||||||
|
|
||||||
|
<h1>{s.name}</h1>
|
||||||
|
<p class="page-sub">Segment · created {fmtDate(s.created_at.slice(0, 10))}</p>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
|
||||||
|
<StatTile label="Ascent" value={fmtElevation(s.elev_gain_m)} />
|
||||||
|
<StatTile
|
||||||
|
label="Record"
|
||||||
|
value={data.leaderboard[0] ? fmtElapsed(data.leaderboard[0].best_s) : '–'}
|
||||||
|
detail={data.leaderboard[0]?.username ?? 'no timed efforts yet'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Leaderboard</h2>
|
||||||
|
{#if data.leaderboard.length === 0}
|
||||||
|
<div class="card empty">No timed efforts yet - ride or run it with a GPS track.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Athlete</th><th>Best time</th><th>Attempts</th><th>Date</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.leaderboard as row, i (row.user_id)}
|
||||||
|
<tr class:me={row.user_id === data.myUserId}>
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
<td><a href="/users/{row.username}">{row.username}</a></td>
|
||||||
|
<td class="time">{fmtElapsed(row.best_s)}</td>
|
||||||
|
<td>{row.attempts}</td>
|
||||||
|
<td>{row.date ? fmtDate(row.date) : '–'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.6rem 1rem 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
tbody tr:not(:last-child) td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
tr.me td {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
}
|
||||||
|
td a {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
|
import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
|
||||||
|
import { matchActivityToSegments } from '$lib/server/segments';
|
||||||
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
||||||
import type { Actions } from './$types';
|
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);
|
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.' });
|
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 errors: string[] = [];
|
||||||
|
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
@@ -122,7 +123,8 @@ export const actions: Actions = {
|
|||||||
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
|
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) {
|
} catch (err) {
|
||||||
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
|
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@
|
|||||||
{#if item.newPeaks.length > 0}
|
{#if item.newPeaks.length > 0}
|
||||||
<span class="bagged">⛰ Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}!</span>
|
<span class="bagged">⛰ Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}!</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if item.segments.length > 0}
|
||||||
|
<span class="bagged">⏱ Segment{item.segments.length > 1 ? 's' : ''} matched: {item.segments.join(', ')}</span>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/each}
|
{/each}
|
||||||
{#each form.errors as message (message)}
|
{#each form.errors as message (message)}
|
||||||
|
|||||||
Reference in New Issue
Block a user