pregenerated segments, tolerant matching, profile segment list, segment map
This commit is contained in:
@@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Mine all activities for popular overlapping stretches and pregenerate
|
||||||
|
// segments from the best ones (mirrors src/lib/server/segments.ts).
|
||||||
|
//
|
||||||
|
// node scripts/generate-segments.js [--db data/streba.db] [--limit 8] [--dry]
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dbPath = args.includes('--db')
|
||||||
|
? args[args.indexOf('--db') + 1]
|
||||||
|
: (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db';
|
||||||
|
const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1], 10) : 8;
|
||||||
|
const DRY = args.includes('--dry');
|
||||||
|
|
||||||
|
const CELL_DEG = 0.00028;
|
||||||
|
const MIN_ACTIVITIES = 5;
|
||||||
|
const MIN_STRETCH_M = 400;
|
||||||
|
const CORRIDOR_M = 45;
|
||||||
|
const SAMPLE_M = 30;
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('busy_timeout = 5000');
|
||||||
|
|
||||||
|
const R = 6371000;
|
||||||
|
function haversine(lat1, lon1, lat2, lon2) {
|
||||||
|
const rad = Math.PI / 180;
|
||||||
|
const dLat = (lat2 - lat1) * rad;
|
||||||
|
const dLon = (lon2 - lon1) * rad;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
const cellKey = (lat, lon) => `${Math.round(lat / CELL_DEG)}:${Math.round(lon / CELL_DEG)}`;
|
||||||
|
function cumDist(pts) {
|
||||||
|
const d = [0];
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
d.push(d[i - 1] + haversine(pts[i - 1].lat, pts[i - 1].lon, pts[i].lat, pts[i].lon));
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mining --------------------------------------------------------------
|
||||||
|
const rows = db.prepare('SELECT id, name, user_id, date, points, bounds FROM activities').all();
|
||||||
|
console.log(`Scanning ${rows.length} activities…`);
|
||||||
|
|
||||||
|
const cellCounts = new Map();
|
||||||
|
const parsed = rows.map((row) => {
|
||||||
|
const points = JSON.parse(row.points);
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const claimed = new Set();
|
||||||
|
for (const seg of db.prepare('SELECT points FROM segments').all()) {
|
||||||
|
for (const p of JSON.parse(seg.points)) claimed.add(cellKey(p.lat, p.lon));
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
for (const activity of parsed) {
|
||||||
|
const d = cumDist(activity.points);
|
||||||
|
let runStart = -1;
|
||||||
|
let gap = 0;
|
||||||
|
let counts = [];
|
||||||
|
const flush = (endIdx) => {
|
||||||
|
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,
|
||||||
|
startIdx: runStart,
|
||||||
|
endIdx,
|
||||||
|
startD: d[runStart],
|
||||||
|
endD: d[endIdx],
|
||||||
|
length,
|
||||||
|
avg,
|
||||||
|
cells,
|
||||||
|
score: length * avg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
runStart = -1;
|
||||||
|
counts = [];
|
||||||
|
};
|
||||||
|
for (let i = 0; i < activity.points.length; i++) {
|
||||||
|
const count = cellCounts.get(cellKey(activity.points[i].lat, activity.points[i].lon)) ?? 0;
|
||||||
|
if (count > MIN_ACTIVITIES && !claimed.has(cellKey(activity.points[i].lat, activity.points[i].lon))) {
|
||||||
|
if (runStart < 0) runStart = i;
|
||||||
|
gap = 0;
|
||||||
|
counts.push(count);
|
||||||
|
} else if (runStart >= 0 && ++gap > 4) {
|
||||||
|
flush(i - gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(activity.points.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => b.score - a.score);
|
||||||
|
const accepted = [];
|
||||||
|
const used = new Set();
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (accepted.length >= LIMIT) break;
|
||||||
|
let overlap = 0;
|
||||||
|
for (const cell of c.cells) if (used.has(cell)) overlap++;
|
||||||
|
if (overlap / c.cells.size > 0.4) continue;
|
||||||
|
for (const cell of c.cells) used.add(cell);
|
||||||
|
accepted.push(c);
|
||||||
|
}
|
||||||
|
console.log(`Found ${candidates.length} candidate stretches, keeping top ${accepted.length}.`);
|
||||||
|
|
||||||
|
// ---- naming --------------------------------------------------------------
|
||||||
|
const nearestPeak = db.prepare(
|
||||||
|
`SELECT name FROM peaks
|
||||||
|
WHERE lat BETWEEN ? - 0.03 AND ? + 0.03 AND lon BETWEEN ? - 0.045 AND ? + 0.045
|
||||||
|
ORDER BY ((lat - ?) * (lat - ?)) + ((lon - ?) * (lon - ?)) * 0.5 ASC LIMIT 1`
|
||||||
|
);
|
||||||
|
const usedNames = new Set(
|
||||||
|
db.prepare('SELECT name FROM segments').all().map((s) => s.name)
|
||||||
|
);
|
||||||
|
function direction(first, last) {
|
||||||
|
const dLat = last.lat - first.lat;
|
||||||
|
const dLon = (last.lon - first.lon) * Math.cos((first.lat * Math.PI) / 180);
|
||||||
|
return Math.abs(dLat) > Math.abs(dLon) ? (dLat > 0 ? 'North' : 'South') : dLon > 0 ? 'East' : 'West';
|
||||||
|
}
|
||||||
|
function nameFor(candidate, slice) {
|
||||||
|
const first = slice[0];
|
||||||
|
const last = slice[slice.length - 1];
|
||||||
|
const gain = last.ele !== null && first.ele !== null ? last.ele - first.ele : 0;
|
||||||
|
const grade = (gain / candidate.length) * 100;
|
||||||
|
const kind = grade > 3 ? 'Climb' : grade < -3 ? 'Descent' : 'Stretch';
|
||||||
|
const mid = slice[Math.floor(slice.length / 2)];
|
||||||
|
const peak = nearestPeak.get(mid.lat, mid.lat, mid.lon, mid.lon, mid.lat, mid.lat, mid.lon, mid.lon);
|
||||||
|
const base = peak ? peak.name : candidate.activity.name;
|
||||||
|
let name = `${base} ${kind}`;
|
||||||
|
if (usedNames.has(name)) name = `${base} ${kind} ${direction(first, last)}`;
|
||||||
|
usedNames.add(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- creation + effort matching -----------------------------------------
|
||||||
|
function checkpoints(points) {
|
||||||
|
const d = cumDist(points);
|
||||||
|
const out = [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;
|
||||||
|
}
|
||||||
|
// mirrors computeEffort in src/lib/server/segments.ts (tolerant matcher)
|
||||||
|
function computeEffort(segPoints, actPoints) {
|
||||||
|
if (segPoints.length < 2 || actPoints.length < 2) return null;
|
||||||
|
const cps = checkpoints(segPoints);
|
||||||
|
const cpD = cumDist(cps);
|
||||||
|
const actD = cumDist(actPoints);
|
||||||
|
const start = cps[0];
|
||||||
|
const maxMiss = Math.max(1, Math.floor(cps.length * 0.1));
|
||||||
|
const cands = [];
|
||||||
|
for (let i = 0; i < actPoints.length; i++) {
|
||||||
|
if (haversine(actPoints[i].lat, actPoints[i].lon, start.lat, start.lon) <= CORRIDOR_M) {
|
||||||
|
if (cands.length === 0 || i - cands[cands.length - 1] > 15) cands.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let matched = false;
|
||||||
|
let best = null;
|
||||||
|
for (const s of cands) {
|
||||||
|
let j = s;
|
||||||
|
let misses = 0;
|
||||||
|
let ok = true;
|
||||||
|
for (let k = 1; k < cps.length; k++) {
|
||||||
|
const limit = actD[s] + cpD[k] * 1.5 + 150;
|
||||||
|
let found = -1;
|
||||||
|
for (let jj = j; jj < actPoints.length && actD[jj] <= limit; jj++) {
|
||||||
|
if (haversine(actPoints[jj].lat, actPoints[jj].lon, cps[k].lat, cps[k].lon) <= CORRIDOR_M) {
|
||||||
|
found = jj;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found >= 0) {
|
||||||
|
j = found;
|
||||||
|
} else if (k === cps.length - 1 || ++misses > maxMiss) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
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 gainOf(points) {
|
||||||
|
let gain = 0;
|
||||||
|
let ref = 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 insertSegment = db.prepare(
|
||||||
|
`INSERT INTO segments (name, creator_id, points, bounds, distance_m, elev_gain_m)
|
||||||
|
VALUES (?, NULL, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
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`
|
||||||
|
);
|
||||||
|
|
||||||
|
// --rematch: wipe and recompute every segment's efforts, then exit
|
||||||
|
if (args.includes('--rematch')) {
|
||||||
|
db.prepare('DELETE FROM segment_efforts').run();
|
||||||
|
for (const segment of db.prepare('SELECT id, name, points FROM segments').all()) {
|
||||||
|
const segPoints = JSON.parse(segment.points);
|
||||||
|
let efforts = 0;
|
||||||
|
for (const activity of parsed) {
|
||||||
|
const effort = computeEffort(segPoints, activity.points);
|
||||||
|
if (effort) {
|
||||||
|
insertEffort.run(segment.id, activity.id, activity.user_id, effort.elapsed_s, activity.date);
|
||||||
|
efforts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Rematched "${segment.name}" - ${efforts} efforts.`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of accepted) {
|
||||||
|
const slice = candidate.activity.points.slice(candidate.startIdx, candidate.endIdx + 1);
|
||||||
|
const name = nameFor(candidate, slice);
|
||||||
|
const km = (candidate.length / 1000).toFixed(1);
|
||||||
|
if (DRY) {
|
||||||
|
console.log(`[dry] would create "${name}" (${km} km, ~${Math.round(candidate.avg)} activities)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let minLat = Infinity, minLon = Infinity, maxLat = -Infinity, maxLon = -Infinity;
|
||||||
|
for (const p of slice) {
|
||||||
|
minLat = Math.min(minLat, p.lat);
|
||||||
|
maxLat = Math.max(maxLat, p.lat);
|
||||||
|
minLon = Math.min(minLon, p.lon);
|
||||||
|
maxLon = Math.max(maxLon, p.lon);
|
||||||
|
}
|
||||||
|
const result = insertSegment.run(
|
||||||
|
name,
|
||||||
|
JSON.stringify(slice.map((p) => ({ lat: p.lat, lon: p.lon, ele: p.ele, t: null }))),
|
||||||
|
JSON.stringify([[minLat, minLon], [maxLat, maxLon]]),
|
||||||
|
candidate.length,
|
||||||
|
gainOf(slice)
|
||||||
|
);
|
||||||
|
const segmentId = Number(result.lastInsertRowid);
|
||||||
|
const segPoints = slice;
|
||||||
|
let efforts = 0;
|
||||||
|
for (const activity of parsed) {
|
||||||
|
const effort = computeEffort(segPoints, activity.points);
|
||||||
|
if (effort) {
|
||||||
|
insertEffort.run(segmentId, activity.id, activity.user_id, effort.elapsed_s, activity.date);
|
||||||
|
efforts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Created "${name}" (${km} km) - ${efforts} efforts.`);
|
||||||
|
}
|
||||||
|
console.log('Done.');
|
||||||
@@ -450,6 +450,51 @@ export function segmentLeaderboard(segmentId: number): {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All segments a user has completed, with their best time, attempts, and rank. */
|
||||||
|
export function userSegments(userId: number): {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
distance_m: number;
|
||||||
|
elev_gain_m: number;
|
||||||
|
attempts: number;
|
||||||
|
best_s: number | null;
|
||||||
|
rank: number | null;
|
||||||
|
}[] {
|
||||||
|
return 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
|
||||||
|
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`
|
||||||
|
)
|
||||||
|
.all({ userId }) as {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
distance_m: number;
|
||||||
|
elev_gain_m: number;
|
||||||
|
attempts: number;
|
||||||
|
best_s: number | null;
|
||||||
|
rank: number | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSegment(id: number, userId: number): boolean {
|
||||||
|
// creators may delete their segments; system-generated ones (no creator)
|
||||||
|
// may be removed by any signed-in user
|
||||||
|
const result = db
|
||||||
|
.prepare('DELETE FROM segments WHERE id = ? AND (creator_id = ? OR creator_id IS NULL)')
|
||||||
|
.run(id, userId);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function segmentEffortsForActivity(activityId: number): { segment_id: number; name: string; elapsed_s: number | null }[] {
|
export function segmentEffortsForActivity(activityId: number): { segment_id: number; name: string; elapsed_s: number | null }[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ function checkpoints<T extends { lat: number; lon: number }>(points: T[]): T[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to find the segment inside an activity: the activity must pass the
|
* Try to find the segment inside an activity: the activity must pass the
|
||||||
* segment start, then every checkpoint in order within the corridor.
|
* segment start, then the checkpoints in order within the corridor. A small
|
||||||
|
* fraction of checkpoints may be missed (GPS noise, brief detours), but the
|
||||||
|
* final checkpoint must be reached. Direction matters.
|
||||||
* Returns the best (fastest) timed effort, an untimed match, or null.
|
* Returns the best (fastest) timed effort, an untimed match, or null.
|
||||||
*/
|
*/
|
||||||
export function computeEffort(
|
export function computeEffort(
|
||||||
@@ -46,7 +48,10 @@ export function computeEffort(
|
|||||||
): { elapsed_s: number | null } | null {
|
): { elapsed_s: number | null } | null {
|
||||||
if (segPoints.length < 2 || actPoints.length < 2) return null;
|
if (segPoints.length < 2 || actPoints.length < 2) return null;
|
||||||
const cps = checkpoints(segPoints);
|
const cps = checkpoints(segPoints);
|
||||||
|
const cpD = cumDist(cps);
|
||||||
|
const actD = cumDist(actPoints);
|
||||||
const start = cps[0];
|
const start = cps[0];
|
||||||
|
const maxMiss = Math.max(1, Math.floor(cps.length * 0.1));
|
||||||
|
|
||||||
// candidate entries: activity points near the segment start (spaced apart to
|
// candidate entries: activity points near the segment start (spaced apart to
|
||||||
// catch repeat laps without re-testing every neighbouring point)
|
// catch repeat laps without re-testing every neighbouring point)
|
||||||
@@ -61,20 +66,27 @@ export function computeEffort(
|
|||||||
let best: number | null = null;
|
let best: number | null = null;
|
||||||
for (const s of candidates) {
|
for (const s of candidates) {
|
||||||
let j = s;
|
let j = s;
|
||||||
|
let misses = 0;
|
||||||
let ok = true;
|
let ok = true;
|
||||||
for (let k = 1; k < cps.length; k++) {
|
for (let k = 1; k < cps.length; k++) {
|
||||||
while (
|
// only search as far along the activity as this checkpoint could
|
||||||
j < actPoints.length &&
|
// plausibly be (with generous slack for wiggly tracks)
|
||||||
haversine(actPoints[j].lat, actPoints[j].lon, cps[k].lat, cps[k].lon) > CORRIDOR_M
|
const limit = actD[s] + cpD[k] * 1.5 + 150;
|
||||||
) {
|
let found = -1;
|
||||||
j++;
|
for (let jj = j; jj < actPoints.length && actD[jj] <= limit; jj++) {
|
||||||
|
if (haversine(actPoints[jj].lat, actPoints[jj].lon, cps[k].lat, cps[k].lon) <= CORRIDOR_M) {
|
||||||
|
found = jj;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (j >= actPoints.length) {
|
if (found >= 0) {
|
||||||
ok = false;
|
j = found;
|
||||||
|
} else if (k === cps.length - 1 || ++misses > maxMiss) {
|
||||||
|
ok = false; // the finish itself may never be missed
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!ok) break; // ran off the end of the activity; later candidates would too
|
if (!ok) continue;
|
||||||
matched = true;
|
matched = true;
|
||||||
const t0 = actPoints[s].t;
|
const t0 = actPoints[s].t;
|
||||||
const t1 = actPoints[j].t;
|
const t1 = actPoints[j].t;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { getSegment, segmentLeaderboard } from '$lib/server/db';
|
import { deleteSegment, getSegment, segmentLeaderboard } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import type { TrackPt } from '$lib/server/segments';
|
import type { TrackPt } from '$lib/server/segments';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ params, locals }) => {
|
export const load: PageServerLoad = ({ params, locals }) => {
|
||||||
@@ -12,6 +12,16 @@ export const load: PageServerLoad = ({ params, locals }) => {
|
|||||||
segment: { ...segment, points: undefined },
|
segment: { ...segment, points: undefined },
|
||||||
latlngs: points.map((p) => [p.lat, p.lon] as [number, number]),
|
latlngs: points.map((p) => [p.lat, p.lon] as [number, number]),
|
||||||
leaderboard: segmentLeaderboard(segment.id),
|
leaderboard: segmentLeaderboard(segment.id),
|
||||||
myUserId: locals.user?.id ?? null
|
myUserId: locals.user?.id ?? null,
|
||||||
|
canDelete:
|
||||||
|
!!locals.user && (segment.creator_id === locals.user.id || segment.creator_id === null)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
delete: async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
|
deleteSegment(Number(params.id), locals.user.id);
|
||||||
|
redirect(303, '/segments');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
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';
|
||||||
@@ -26,10 +27,27 @@
|
|||||||
<title>{s.name} · Streba</title>
|
<title>{s.name} · Streba</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<MapSlot />
|
||||||
|
|
||||||
<a class="back" href="/segments">← Segments</a>
|
<a class="back" href="/segments">← Segments</a>
|
||||||
|
|
||||||
<h1>{s.name}</h1>
|
<div class="head">
|
||||||
<p class="page-sub">Segment · created {fmtDate(s.created_at.slice(0, 10))}</p>
|
<div>
|
||||||
|
<h1>{s.name}</h1>
|
||||||
|
<p class="page-sub">Segment · created {fmtDate(s.created_at.slice(0, 10))}</p>
|
||||||
|
</div>
|
||||||
|
{#if data.canDelete}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (!confirm('Delete this segment and all its efforts?')) cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="btn danger" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="kpis">
|
<div class="kpis">
|
||||||
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
|
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
|
||||||
@@ -81,6 +99,12 @@
|
|||||||
background: var(--wash);
|
background: var(--wash);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
.kpis {
|
.kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { countAscents, getProfile, listActivities, listAscents, userStats } from '$lib/server/db';
|
import {
|
||||||
|
countAscents,
|
||||||
|
getProfile,
|
||||||
|
listActivities,
|
||||||
|
listAscents,
|
||||||
|
userSegments,
|
||||||
|
userStats
|
||||||
|
} from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ params }) => {
|
export const load: PageServerLoad = ({ params }) => {
|
||||||
@@ -12,6 +19,7 @@ export const load: PageServerLoad = ({ params }) => {
|
|||||||
stats: userStats(profile.id),
|
stats: userStats(profile.id),
|
||||||
peaksReached: countAscents(profile.id),
|
peaksReached: countAscents(profile.id),
|
||||||
highestAscent: ascents[0] ?? null,
|
highestAscent: ascents[0] ?? null,
|
||||||
|
segments: userSegments(profile.id),
|
||||||
activities: listActivities(profile.id).slice(0, 10)
|
activities: listActivities(profile.id).slice(0, 10)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
import { fmtDistance, fmtDuration, fmtElapsed, fmtElevation } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const p = $derived(data.profile);
|
const p = $derived(data.profile);
|
||||||
@@ -41,6 +41,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.segments.length > 0}
|
||||||
|
<h2>Segments</h2>
|
||||||
|
<div class="card seg-list">
|
||||||
|
{#each data.segments as segment (segment.id)}
|
||||||
|
<a class="seg-row" href="/segments/{segment.id}">
|
||||||
|
<span class="seg-name">
|
||||||
|
{#if segment.rank === 1}<span class="crown" title="Course record">👑</span>{/if}
|
||||||
|
{segment.name}
|
||||||
|
</span>
|
||||||
|
<span class="seg-meta">
|
||||||
|
{fmtDistance(segment.distance_m)} · {segment.attempts}
|
||||||
|
{segment.attempts === 1 ? 'attempt' : 'attempts'}
|
||||||
|
{#if segment.best_s !== null}
|
||||||
|
· best {fmtElapsed(segment.best_s)}
|
||||||
|
{#if segment.rank !== null}· #{segment.rank}{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2>Recent activities</h2>
|
<h2>Recent activities</h2>
|
||||||
{#if data.activities.length === 0}
|
{#if data.activities.length === 0}
|
||||||
<div class="card empty">No activities yet.</div>
|
<div class="card empty">No activities yet.</div>
|
||||||
@@ -91,6 +113,32 @@
|
|||||||
.list :global(.item:not(:last-child)) {
|
.list :global(.item:not(:last-child)) {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.seg-list > .seg-row:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.seg-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem 1.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 120ms;
|
||||||
|
}
|
||||||
|
.seg-row:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.seg-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.crown {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.seg-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user