Compare commits

...
7 Commits
24 changed files with 1835 additions and 17 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
// Audit activity types against their measured stats and fix contradictions.
//
// node scripts/audit-types.js # dry run: show proposed changes
// node scripts/audit-types.js --apply # apply + log to data/type-fixes.json
//
// Custom/curated types (skitour, vita, iceskate, …) are left alone; only
// clear physics contradictions in the generic foot/bike types plus the
// "outdoor" fallback are reclassified, using centroids computed from the
// database's own labelled activities.
import Database from 'better-sqlite3';
import fs from 'node:fs';
const APPLY = process.argv.includes('--apply');
const dbPath = (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db';
const db = new Database(dbPath);
const rows = db
.prepare(
`SELECT id, name, type, distance_m, elev_gain_m,
coalesce(moving_s, duration_s) time_s
FROM activities`
)
.all()
.map((r) => ({
...r,
kmh: r.time_s > 0 ? (r.distance_m / r.time_s) * 3.6 : null,
gainPerKm: r.distance_m > 0 ? r.elev_gain_m / (r.distance_m / 1000) : 0
}));
// generic classes we are willing to assign
const FOOT_SLOW = new Set(['hike', 'walk', 'walking']);
const FOOT_FAST = new Set(['run', 'running', 'trail run']);
const BIKE = new Set(['ride', 'cycling', 'bike', 'mtb', 'gravel']);
const GENERIC = new Set([...FOOT_SLOW, ...FOOT_FAST, ...BIKE, 'outdoor']);
// centroids from the user's own labelled data (fall back to sensible defaults)
function centroid(types, defKmh, defGain, defKm) {
const sample = rows.filter((r) => types.has(r.type) && r.kmh !== null);
if (sample.length < 3) return { kmh: defKmh, gainPerKm: defGain, km: defKm };
return {
kmh: sample.reduce((s, r) => s + r.kmh, 0) / sample.length,
gainPerKm: sample.reduce((s, r) => s + r.gainPerKm, 0) / sample.length,
km: sample.reduce((s, r) => s + r.distance_m / 1000, 0) / sample.length
};
}
const CLASSES = [
{ name: 'hike', c: centroid(FOOT_SLOW, 4, 70, 10) },
{ name: 'run', c: centroid(FOOT_FAST, 11, 25, 8) },
{ name: 'ride', c: centroid(new Set(['ride', 'cycling', 'bike']), 21, 15, 40) },
{ name: 'mtb', c: centroid(new Set(['mtb']), 13.5, 45, 27) }
];
function classify(r) {
if (r.kmh === null) return null;
let best = null;
let bestScore = Infinity;
for (const cls of CLASSES) {
// normalised distance in (speed, climb-rate, length) space
const ds = (r.kmh - cls.c.kmh) / 6;
const dg = (r.gainPerKm - cls.c.gainPerKm) / 40;
const dd = (r.distance_m / 1000 - cls.c.km) / 20;
const score = ds * ds + dg * dg + dd * dd;
if (score < bestScore) {
bestScore = score;
best = cls.name;
}
}
return best;
}
function contradiction(r) {
if (r.kmh === null) return false;
if (r.type === 'outdoor') return true;
if (/^\d+$/.test(r.type)) return true;
if (FOOT_SLOW.has(r.type) && r.kmh > 9) return true;
if (FOOT_FAST.has(r.type) && (r.kmh < 4.5 || r.kmh > 16)) return true;
if (BIKE.has(r.type) && r.kmh < 5.5) return true;
return false;
}
const changes = [];
for (const r of rows) {
if (!GENERIC.has(r.type) && !/^\d+$/.test(r.type)) continue; // curated type: hands off
if (!contradiction(r)) continue;
const suggestion = classify(r);
if (!suggestion || suggestion === r.type) continue;
changes.push({
id: r.id,
name: r.name,
from: r.type,
to: suggestion,
kmh: r.kmh === null ? null : Math.round(r.kmh * 10) / 10,
gainPerKm: Math.round(r.gainPerKm)
});
}
// borderline cases worth a human look (reported, never auto-changed)
const review = rows.filter(
(r) =>
(r.type === 'ride' && r.gainPerKm > 35 && r.kmh < 16) ||
(r.type === 'mtb' && r.gainPerKm < 12 && r.kmh > 17) ||
(FOOT_FAST.has(r.type) && r.kmh !== null && r.kmh < 6) ||
(FOOT_SLOW.has(r.type) && r.kmh !== null && r.kmh > 7 && r.kmh <= 9)
);
if (review.length > 0) {
console.log('\nBorderline (left unchanged - review by hand if they look off):');
console.table(
review.map((r) => ({
id: r.id,
name: r.name,
type: r.type,
kmh: Math.round((r.kmh ?? 0) * 10) / 10,
gainPerKm: Math.round(r.gainPerKm),
km: Math.round(r.distance_m / 100) / 10
}))
);
}
if (changes.length === 0) {
console.log('No contradictions found.');
process.exit(0);
}
console.table(changes);
if (!APPLY) {
console.log(`Dry run - ${changes.length} change(s) proposed. Re-run with --apply.`);
process.exit(0);
}
const update = db.prepare('UPDATE activities SET type = ? WHERE id = ?');
db.transaction(() => {
for (const change of changes) update.run(change.to, change.id);
})();
fs.writeFileSync(
(process.env.STREBA_DATA_DIR ?? 'data') + '/type-fixes.json',
JSON.stringify({ applied: new Date().toISOString(), changes }, null, 2)
);
console.log(`Applied ${changes.length} change(s); log written to data/type-fixes.json.`);
+281
View File
@@ -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.');
+5
View File
@@ -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) {
+3 -1
View File
@@ -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)
);
}
+13 -1
View File
@@ -31,10 +31,22 @@ const TYPE_SLOT: Record<string, number> = {
'via ferrata': 3,
alpinism: 3,
ski: 6,
alpineski: 6,
skitour: 6,
langlauf: 6,
'backcountry ski': 6,
'nordic ski': 6,
snowboard: 6,
snowshoe: 6
snowshoe: 6,
mtb: 2,
gravel: 2,
vita: 1,
iceskate: 4,
inlineskate: 4,
inline: 4,
'hike & fly': 3,
watersport: 5,
segment: 7
};
function hash(s: string): number {
+14
View File
@@ -0,0 +1,14 @@
// Lift-assisted downhill types: their recorded elevation gain comes from
// lifts, so it doesn't count toward ascent totals. Touring variants
// ("skitour", "backcountry ski", "langlauf") earn their metres and do count.
export const LIFT_ASSISTED_TYPES = [
'ski',
'alpine ski',
'alpineski',
'downhill ski',
'snowboard'
];
export function countsTowardAscent(type: string): boolean {
return !LIFT_ASSISTED_TYPES.includes(type.toLowerCase().trim());
}
+122 -4
View File
@@ -41,6 +41,7 @@
highlightId = null,
focus = null,
hoverPoint = null,
selection = null,
height = '100%',
onpeakclick,
onviewport
@@ -50,6 +51,7 @@
highlightId?: number | null;
focus?: [[number, number], [number, number]] | null;
hoverPoint?: { lat: number; lon: number } | null;
selection?: [number, number][] | null;
height?: string;
onpeakclick?: (id: number) => void;
onviewport?: (view: Viewport) => void;
@@ -103,7 +105,8 @@
date: track.date ?? '',
distance_m: track.distance_m ?? 0,
color: dark ? color.dark : color.light,
dim: highlightId !== null && track.id !== highlightId
dim: highlightId !== null && track.id !== highlightId,
highlight: highlightId !== null && track.id === highlightId
},
geometry: {
type: 'LineString' as const,
@@ -188,6 +191,60 @@
hoverGeojson()
);
});
function selectionGeojson(): FeatureCollection {
return {
type: 'FeatureCollection',
features:
selection && selection.length > 1
? [
{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: selection.map(([lat, lon]) => [lon, lat])
}
}
]
: []
};
}
$effect(() => {
void selection;
if (!loaded) return;
(map?.getSource('selection') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
selectionGeojson()
);
});
// start/finish markers for the highlighted route or the live selection
function endpointsGeojson(): FeatureCollection {
const line =
selection && selection.length > 1
? selection
: highlightId !== null
? tracks.find((t) => t.id === highlightId)?.latlngs
: undefined;
if (!line || line.length < 2) return { type: 'FeatureCollection', features: [] };
const make = (kind: string, [lat, lon]: [number, number]) => ({
type: 'Feature' as const,
properties: { kind },
geometry: { type: 'Point' as const, coordinates: [lon, lat] }
});
return {
type: 'FeatureCollection',
features: [make('start', line[0]), make('end', line[line.length - 1])]
};
}
$effect(() => {
void selection;
void tracks;
void highlightId;
if (!loaded) return;
(map?.getSource('endpoints') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
endpointsGeojson()
);
});
// animate the camera when a page requests a new focus; remember the
// unfocused camera so returning to the overview restores it instead of
@@ -251,6 +308,15 @@
);
map.addSource('tracks', { type: 'geojson', data: tracksGeojson(dark) });
// soft glow behind the highlighted route so it pops off the basemap
map.addLayer({
id: 'tracks-halo',
type: 'line',
source: 'tracks',
filter: ['==', ['get', 'highlight'], true],
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': ['get', 'color'], 'line-width': 14, 'line-opacity': 0.22 }
});
map.addLayer({
id: 'tracks-casing',
type: 'line',
@@ -258,8 +324,8 @@
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': dark ? '#1a1a19' : '#ffffff',
'line-width': 5,
'line-opacity': ['case', ['get', 'dim'], 0.08, 0.6]
'line-width': ['case', ['get', 'highlight'], 8, 5],
'line-opacity': ['case', ['get', 'dim'], 0.08, ['get', 'highlight'], 0.95, 0.6]
}
});
map.addLayer({
@@ -269,7 +335,7 @@
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': ['get', 'color'],
'line-width': 2.5,
'line-width': ['case', ['get', 'highlight'], 4.5, 2.5],
'line-opacity': ['case', ['get', 'dim'], 0.15, 1]
}
});
@@ -310,6 +376,58 @@
}
});
// live preview of a segment being created (accent, on top of tracks)
map.addSource('selection', { type: 'geojson', data: selectionGeojson() });
map.addLayer({
id: 'selection-line',
type: 'line',
source: 'selection',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': dark ? '#e66767' : '#e34948',
'line-width': 5,
'line-opacity': 0.95
}
});
// start / finish markers
map.addSource('endpoints', { type: 'geojson', data: endpointsGeojson() });
map.addLayer({
id: 'endpoints-circles',
type: 'circle',
source: 'endpoints',
paint: {
'circle-radius': 9,
'circle-color': [
'case',
['==', ['get', 'kind'], 'start'],
'#0ca30c',
dark ? '#eef2f0' : '#0b0b0b'
],
'circle-stroke-width': 2,
'circle-stroke-color': dark ? '#1a1a19' : '#ffffff'
}
});
map.addLayer({
id: 'endpoints-glyphs',
type: 'symbol',
source: 'endpoints',
layout: {
'text-field': ['case', ['==', ['get', 'kind'], 'start'], 'S', 'F'],
'text-size': 10,
'text-allow-overlap': true,
'text-ignore-placement': true
},
paint: {
'text-color': [
'case',
['==', ['get', 'kind'], 'start'],
'#ffffff',
dark ? '#0b0b0b' : '#ffffff'
]
}
});
// chart-hover position marker, matching the chart's crosshair dot
map.addSource('hover-point', { type: 'geojson', data: hoverGeojson() });
map.addLayer({
+10
View File
@@ -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')}`;
}
+8
View File
@@ -11,6 +11,7 @@ let highlightId = $state<number | null>(null);
let focus = $state<Bounds | null>(null);
let detailTrack = $state<MapTrack | null>(null);
let hoverPoint = $state<{ lat: number; lon: number } | null>(null);
let selection = $state<[number, number][] | null>(null);
let carrier = $state<HTMLElement | null>(null);
let active = $state(false);
let wanted = $state(false);
@@ -37,6 +38,12 @@ export const mapState = {
get hoverPoint() {
return hoverPoint;
},
get selection() {
return selection;
},
setSelection(latlngs: [number, number][] | null) {
selection = latlngs;
},
get carrier() {
return carrier;
},
@@ -78,6 +85,7 @@ export const mapState = {
focus = null;
detailTrack = null;
hoverPoint = null;
selection = null;
peakClickHandler = null;
viewportHandler = null;
},
+211 -2
View File
@@ -3,6 +3,12 @@ import fs from 'node:fs';
import path from 'node:path';
import { PEAKS } from './peaks-seed';
import { assignMinzoom, scorePeak } from './peak-score';
import { LIFT_ASSISTED_TYPES } from '$lib/activity-rules';
// SQL fragment: elevation gain that counts toward ascent totals
// (lift-assisted downhill types contribute 0)
const LIFT_LIST = LIFT_ASSISTED_TYPES.map((t) => `'${t}'`).join(', ');
export const COUNTED_GAIN = `CASE WHEN lower(trim(type)) IN (${LIFT_LIST}) THEN 0 ELSE elev_gain_m END`;
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -60,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,
@@ -234,7 +262,7 @@ export function userStats(userId: number): {
.prepare(
`SELECT count(*) activities,
coalesce(sum(distance_m), 0) distance_m,
coalesce(sum(elev_gain_m), 0) elev_gain_m,
coalesce(sum(${COUNTED_GAIN}), 0) elev_gain_m,
coalesce(sum(coalesce(moving_s, duration_s, 0)), 0) moving_s
FROM activities WHERE user_id = ?`
)
@@ -252,7 +280,7 @@ export function communityTotals(): {
`SELECT (SELECT count(*) FROM users) users,
count(*) activities,
coalesce(sum(distance_m), 0) distance_m,
coalesce(sum(elev_gain_m), 0) elev_gain_m
coalesce(sum(${COUNTED_GAIN}), 0) elev_gain_m
FROM activities`
)
.get() as { users: number; activities: number; distance_m: number; elev_gain_m: number };
@@ -360,6 +388,187 @@ 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<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;
}
/** 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, 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
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, 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. 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;
distance_m: number;
elev_gain_m: number;
attempts: number;
best_s: number | null;
best_type: string | null;
rank: number | null;
}[] {
const base = db
.prepare(
`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`
)
.all({ userId }) as {
id: number;
name: string;
distance_m: number;
elev_gain_m: number;
attempts: number;
}[];
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 {
// 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;
}
/** Every effort on a segment, fastest first (untimed last, by date). */
export function segmentAllEfforts(segmentId: number): {
id: number;
user_id: 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, a.type,
e.elapsed_s, e.date
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 = ?
ORDER BY e.elapsed_s IS NULL, e.elapsed_s ASC, e.date DESC`
)
.all(segmentId) as {
id: number;
user_id: number;
username: string;
activity_id: number;
activity_name: string;
type: string;
elapsed_s: number | null;
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(
+342
View File
@@ -0,0 +1,342 @@
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 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.
*/
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 cpD = cumDist(cps);
const actD = cumDist(actPoints);
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
// 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 misses = 0;
let ok = true;
for (let k = 1; k < cps.length; k++) {
// only search as far along the activity as this checkpoint could
// plausibly be (with generous slack for wiggly tracks)
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; // the finish itself may never be missed
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 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);
}
+2
View File
@@ -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' }
];
@@ -167,6 +168,7 @@
highlightId={mapState.highlightId}
focus={mapState.focus}
hoverPoint={mapState.hoverPoint}
selection={mapState.selection}
onpeakclick={(id) => mapState.handlePeakClick(id)}
onviewport={(view) => mapState.handleViewport(view)}
/>
+7 -3
View File
@@ -1,4 +1,5 @@
import { db, listActivities } from '$lib/server/db';
import { COUNTED_GAIN, db, listActivities } from '$lib/server/db';
import { countsTowardAscent } from '$lib/activity-rules';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => {
@@ -9,7 +10,7 @@ export const load: PageServerLoad = ({ locals }) => {
`SELECT substr(coalesce(date, substr(created_at, 1, 10)), 1, 4) year,
count(*) count,
sum(distance_m) distance_m,
sum(elev_gain_m) elev_gain_m,
sum(${COUNTED_GAIN}) elev_gain_m,
sum(coalesce(moving_s, duration_s, 0)) moving_s
FROM activities WHERE user_id = ?
GROUP BY year ORDER BY year DESC`
@@ -28,7 +29,10 @@ export const load: PageServerLoad = ({ locals }) => {
totals: {
count: activities.length,
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_m, 0),
elev_gain_m: activities.reduce(
(sum, a) => sum + (countsTowardAscent(a.type) ? a.elev_gain_m : 0),
0
),
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
}
};
+5 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import ActivityItem from '$lib/components/ActivityItem.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import { countsTowardAscent } from '$lib/activity-rules';
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
let { data } = $props();
@@ -19,7 +20,10 @@
const shown = $derived({
count: filtered.length,
distance_m: filtered.reduce((sum, a) => sum + a.distance_m, 0),
elev_gain_m: filtered.reduce((sum, a) => sum + a.elev_gain_m, 0),
elev_gain_m: filtered.reduce(
(sum, a) => sum + (countsTowardAscent(a.type) ? a.elev_gain_m : 0),
0
),
moving_s: filtered.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
});
const suffix = $derived(selectedYear === null ? '' : ` (${selectedYear})`);
+21 -1
View File
@@ -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();
+153
View File
@@ -9,6 +9,7 @@
fmtDate,
fmtDistance,
fmtDuration,
fmtElapsed,
fmtElevation,
fmtSpeed,
} from "$lib/format";
@@ -23,6 +24,31 @@
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);
});
// live preview of the selected slice on the map
$effect(() => {
if (!segmenting) {
mapState.setSelection(null);
return;
}
const from = Math.min(segStart, segEnd);
const to = Math.max(segStart, segEnd);
const slice: [number, number][] = [];
for (let i = 0; i < data.trackD.length; i++) {
if (data.trackD[i] >= from && data.trackD[i] <= to)
slice.push(data.latlngs[i]);
}
mapState.setSelection(slice);
return () => mapState.setSelection(null);
});
// chart hover -> marker on the map at the matching track position
function pointAt(d: number): [number, number] {
const trackD = data.trackD;
@@ -192,6 +218,65 @@
</div>
{/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}&nbsp;({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 />
{#if data.profile.length > 1}
@@ -264,6 +349,7 @@
align-items: center;
flex-wrap: wrap;
width: 100%;
margin-bottom: 1.25rem;
}
.edit-form input {
padding: 0.5rem 0.7rem;
@@ -293,6 +379,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);
+30
View File
@@ -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}`);
}
};
+149
View File
@@ -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>
+28
View File
@@ -0,0 +1,28 @@
import { error, redirect } from '@sveltejs/kit';
import { deleteSegment, getSegment, segmentAllEfforts, segmentLeaderboard } from '$lib/server/db';
import type { Actions, 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),
efforts: segmentAllEfforts(segment.id),
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');
}
};
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
import { enhance } from '$app/forms';
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<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
// 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>
<MapSlot />
<a class="back" href="/segments">← Segments</a>
<div class="head">
<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">
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
<StatTile label="Ascent" value={fmtElevation(s.elev_gain_m)} />
<StatTile
label={selectedType ? `Record (${selectedType})` : 'Record'}
value={board[0] ? fmtElapsed(board[0].best_s) : ''}
detail={board[0]?.username ?? 'no timed efforts yet'}
/>
</div>
{#if types.length > 1}
<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>
{: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 board as row, i (row.user_id + row.type)}
<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}
{#if efforts.length > 0}
<h2>All efforts{selectedType ? ` · ${selectedType}` : ''} ({efforts.length})</h2>
<div class="card table-wrap">
<table>
<thead>
<tr><th>Athlete</th><th>Time</th><th>Date</th><th>Activity</th></tr>
</thead>
<tbody>
{#each efforts as effort (effort.id)}
<tr class:me={effort.user_id === data.myUserId}>
<td><a href="/users/{effort.username}">{effort.username}</a></td>
<td class="time">{effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : ''}</td>
<td>{effort.date ? fmtDate(effort.date) : ''}</td>
<td><a href="/activities/{effort.activity_id}">{effort.activity_name}</a></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);
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.kpis {
display: grid;
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;
}
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>
+4 -2
View File
@@ -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'}`);
}
+3
View File
@@ -91,6 +91,9 @@
{#if item.newPeaks.length > 0}
<span class="bagged">⛰ Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}!</span>
{/if}
{#if item.segments.length > 0}
<span class="bagged">⏱ Segment{item.segments.length > 1 ? 's' : ''} matched: {item.segments.join(', ')}</span>
{/if}
</p>
{/each}
{#each form.errors as message (message)}
+9 -1
View File
@@ -1,5 +1,12 @@
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';
export const load: PageServerLoad = ({ params }) => {
@@ -12,6 +19,7 @@ export const load: PageServerLoad = ({ params }) => {
stats: userStats(profile.id),
peaksReached: countAscents(profile.id),
highestAscent: ascents[0] ?? null,
segments: userSegments(profile.id),
activities: listActivities(profile.id).slice(0, 10)
};
};
+49 -1
View File
@@ -2,7 +2,7 @@
import Avatar from '$lib/components/Avatar.svelte';
import ActivityItem from '$lib/components/ActivityItem.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();
const p = $derived(data.profile);
@@ -41,6 +41,28 @@
</div>
{/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.best_type}&nbsp;({segment.best_type}){/if}
{#if segment.rank !== null}· #{segment.rank}{/if}
{/if}
</span>
</a>
{/each}
</div>
{/if}
<h2>Recent activities</h2>
{#if data.activities.length === 0}
<div class="card empty">No activities yet.</div>
@@ -91,6 +113,32 @@
.list :global(.item:not(:last-child)) {
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 {
padding: 1.5rem;
text-align: center;