add user accounts and OSM peak catalog with zoom-based notability

This commit is contained in:
Vincent van der Wal
2026-07-22 11:10:59 +02:00
parent 196f30521c
commit b17f56b194
26 changed files with 1019 additions and 191 deletions
+20 -8
View File
@@ -1,16 +1,19 @@
# Streba
The GPX analyser, reborn. A single-user, self-hosted web app for analysing GPX
tracks and bagging Alpine peaks.
The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks and
bagging Alpine peaks.
- **Accounts** - anyone can sign up; activities and climbed peaks are per user.
- **Upload** GPX files (drag & drop, multiple at once) - distance, ascent,
moving time and an elevation profile are computed on the spot.
- **Activities** - every track on a map with stats and an interactive
elevation chart.
- **Worldmap** - all your tracks together on the dashboard.
- **Peaks** - a checklist of notable Alpine summits. Cross them off by hand,
or let an uploaded track bag them automatically when it passes within
150 m of a summit.
- **Peaks** - the OSM peaks of the whole Alpine region on a map. Zooming out
shows only the most notable summits (elevation, Wikipedia/Wikidata
presence and tagged prominence decide); zooming in reveals the rest. Cross
them off by hand, or let an uploaded track bag them automatically when it
passes within 150 m of a summit.
## Stack
@@ -39,6 +42,15 @@ Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to
## Peaks data
The seed list of peaks lives in `src/lib/server/peaks-seed.ts` - edit it to
taste; new entries are inserted on the next start, and your climbed state is
kept.
A fresh database is seeded with ~80 curated famous peaks so the app works out
of the box. To load **all named OSM peaks of the Alps** (tens of thousands),
run the importer once (and re-run whenever you want fresh OSM data):
```sh
node scripts/import-peaks.js
```
It fetches `natural=peak` nodes from the Overpass API for the Alpine bounding
box, scores each peak for notability, precomputes the zoom level from which
it appears on the map, and merges the curated seed peaks into their OSM
counterparts without losing anyone's recorded ascents.
+11
View File
@@ -21,6 +21,7 @@
"svelte": "^5.56.7",
"svelte-check": "^4.7.3",
"typescript": "^6.0.3",
"undici": "^7.28.0",
"vite": "^8.1.5"
}
},
@@ -2799,6 +2800,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+1
View File
@@ -24,6 +24,7 @@
"svelte": "^5.56.7",
"svelte-check": "^4.7.3",
"typescript": "^6.0.3",
"undici": "^7.28.0",
"vite": "^8.1.5"
}
}
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
// Import all named OSM peaks in the Alpine region into the Streba database.
//
// node scripts/import-peaks.js [--db data/streba.db]
//
// Fetches natural=peak nodes from the Overpass API, scores them for
// notability, assigns per-zoom visibility (minzoom), upgrades the built-in
// seed peaks to their OSM counterparts (keeping everyone's ascents), and
// recomputes visibility across the whole catalog. Idempotent - safe to
// re-run to refresh the data.
import Database from 'better-sqlite3';
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import { assignMinzoom, scorePeak } from '../src/lib/server/peak-score.js';
// honor HTTP(S)_PROXY/NO_PROXY env vars (no-op when unset)
setGlobalDispatcher(new EnvHttpProxyAgent());
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
// the Alps, generously: Nice to Vienna
const BBOX = '43.0,4.7,48.7,16.8';
const QUERY = `[out:json][timeout:600];node["natural"="peak"]["name"](${BBOX});out body;`;
const dbPath = process.argv.includes('--db')
? process.argv[process.argv.indexOf('--db') + 1]
: (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db';
function parseEle(raw) {
if (raw === undefined) return null;
const ele = parseFloat(String(raw).replace(',', '.'));
return Number.isFinite(ele) && ele > 0 && ele < 5000 ? ele : null;
}
console.log(`Fetching peaks from Overpass (bbox ${BBOX}) - this can take a few minutes…`);
const response = await fetch(OVERPASS_URL, {
method: 'POST',
body: 'data=' + encodeURIComponent(QUERY),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'streba-peak-import/1.0 (self-hosted GPX analyser)'
}
});
if (!response.ok) {
console.error(`Overpass request failed: ${response.status} ${response.statusText}`);
process.exit(1);
}
const osm = await response.json();
console.log(`Received ${osm.elements.length} peaks.`);
const peaks = osm.elements
.filter((el) => el.type === 'node' && el.tags?.name)
.map((el) => ({
osm_id: `node:${el.id}`,
name: el.tags.name,
elevation_m: parseEle(el.tags.ele),
lat: el.lat,
lon: el.lon,
wikipedia: el.tags.wikipedia ?? null,
wikidata: el.tags.wikidata ?? null,
prominence_m: parseEle(el.tags.prominence),
score: 0,
minzoom: 14
}));
for (const p of peaks) p.score = scorePeak(p);
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const hasPeaksTable = db
.prepare(`SELECT count(*) n FROM sqlite_master WHERE type = 'table' AND name = 'peaks'`)
.get().n;
if (!hasPeaksTable) {
console.error(`No peaks table in ${dbPath} - start the app once first so it creates the schema.`);
process.exit(1);
}
const upsert = db.prepare(`
INSERT INTO peaks (osm_id, name, elevation_m, lat, lon, wikipedia, wikidata, score, minzoom)
VALUES (@osm_id, @name, @elevation_m, @lat, @lon, @wikipedia, @wikidata, @score, @minzoom)
ON CONFLICT (osm_id) DO UPDATE SET
name = excluded.name, elevation_m = excluded.elevation_m,
lat = excluded.lat, lon = excluded.lon,
wikipedia = excluded.wikipedia, wikidata = excluded.wikidata,
score = excluded.score
`);
db.transaction(() => {
for (const p of peaks) upsert.run(p);
// upgrade curated seed rows to their OSM counterpart, keeping ascents.
// OSM names are often multilingual ("Mont Blanc / Monte Bianco"), so match
// by name containment first, then by plain summit proximity.
const seeds = db.prepare(`SELECT * FROM peaks WHERE osm_id LIKE 'seed:%'`).all();
const candidatesFor = db.prepare(
`SELECT id, name, lat, lon, elevation_m FROM peaks
WHERE osm_id LIKE 'node:%' AND abs(lat - ?) < 0.01 AND abs(lon - ?) < 0.015`
);
let upgraded = 0;
for (const seed of seeds) {
const candidates = candidatesFor.all(seed.lat, seed.lon);
const seedName = seed.name.toLowerCase();
const dist = (c) => Math.hypot(c.lat - seed.lat, (c.lon - seed.lon) * 0.7);
const byName = candidates
.filter((c) => {
const n = c.name.toLowerCase();
return n.includes(seedName) || seedName.includes(n);
})
.sort((a, b) => dist(a) - dist(b))[0];
const byProximity = candidates
.filter(
(c) =>
dist(c) < 0.004 &&
(c.elevation_m == null || Math.abs(c.elevation_m - seed.elevation_m) < 200)
)
.sort((a, b) => dist(a) - dist(b))[0];
const match = byName ?? byProximity;
if (match) {
db.prepare('UPDATE OR IGNORE ascents SET peak_id = ? WHERE peak_id = ?').run(match.id, seed.id);
db.prepare('DELETE FROM peaks WHERE id = ?').run(seed.id);
upgraded++;
}
}
console.log(`Upgraded ${upgraded}/${seeds.length} seed peaks to OSM nodes.`);
// recompute zoom visibility across the merged catalog
const all = db.prepare('SELECT id, lat, lon, score FROM peaks').all();
assignMinzoom(all);
const setZoom = db.prepare('UPDATE peaks SET minzoom = ? WHERE id = ?');
for (const p of all) setZoom.run(p.minzoom, p.id);
})();
const total = db.prepare('SELECT count(*) n FROM peaks').get().n;
const byZoom = db
.prepare('SELECT minzoom, count(*) n FROM peaks GROUP BY minzoom ORDER BY minzoom')
.all();
console.log(`Catalog now holds ${total} peaks.`);
console.log('Visible from zoom:', byZoom.map((r) => `${r.minzoom}: ${r.n}`).join(', '));
+7 -1
View File
@@ -1,5 +1,11 @@
import type { User } from '$lib/server/auth';
declare global {
namespace App {}
namespace App {
interface Locals {
user: User | null;
}
}
}
export {};
+19
View File
@@ -0,0 +1,19 @@
import { error, redirect, type Handle } from '@sveltejs/kit';
import { validateSession } from '$lib/server/auth';
const PUBLIC_PATHS = new Set(['/login', '/signup']);
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = validateSession(event.cookies.get('session'));
const path = event.url.pathname;
if (!event.locals.user && !PUBLIC_PATHS.has(path)) {
if (path.startsWith('/api/')) error(401, 'Not signed in');
redirect(303, '/login');
}
if (event.locals.user && PUBLIC_PATHS.has(path)) {
redirect(303, '/');
}
return resolve(event);
};
+27 -1
View File
@@ -19,16 +19,26 @@
const STYLE_URL = 'https://maptiler.servert.nl/styles/minimal-world-maps/style.json';
const TRACK_COLOR = '#2a78d6';
export interface Viewport {
minLat: number;
minLon: number;
maxLat: number;
maxLon: number;
zoom: number;
}
let {
tracks = [],
peaks = [],
height = '420px',
onpeakclick
onpeakclick,
onviewport
}: {
tracks?: MapTrack[];
peaks?: MapPeak[];
height?: string;
onpeakclick?: (id: number) => void;
onviewport?: (view: Viewport) => void;
} = $props();
let container: HTMLDivElement;
@@ -108,6 +118,22 @@
renderPeaks();
if (onviewport) {
const report = () => {
if (!map) return;
const b = map.getBounds();
onviewport({
minLat: b.getSouth(),
minLon: b.getWest(),
maxLat: b.getNorth(),
maxLon: b.getEast(),
zoom: map.getZoom()
});
};
map.on('moveend', report);
map.once('load', report);
}
const bounds = new maplibre.LngLatBounds();
for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]);
for (const p of peaks) bounds.extend([p.lon, p.lat]);
+77
View File
@@ -0,0 +1,77 @@
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
import { db } from './db';
const SESSION_DAYS = 30;
export interface User {
id: number;
username: string;
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex');
return `scrypt:${salt}:${hash}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [scheme, salt, hash] = stored.split(':');
if (scheme !== 'scrypt' || !salt || !hash) return false;
const candidate = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
return timingSafeEqual(candidate, Buffer.from(hash, 'hex'));
}
export function createUser(username: string, password: string): User {
const result = db
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
.run(username, hashPassword(password));
return { id: Number(result.lastInsertRowid), username };
}
export function findUser(username: string): { id: number; username: string; password_hash: string } | undefined {
return db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get(username) as
| { id: number; username: string; password_hash: string }
| undefined;
}
function tokenId(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function createSession(userId: number): string {
const token = randomBytes(32).toString('hex');
const expires = Date.now() + SESSION_DAYS * 86400_000;
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
tokenId(token),
userId,
expires
);
return token;
}
export function validateSession(token: string | undefined): User | null {
if (!token) return null;
const row = db
.prepare(
`SELECT s.id sid, s.expires_at, u.id, u.username
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ?`
)
.get(tokenId(token)) as { sid: string; expires_at: number; id: number; username: string } | undefined;
if (!row) return null;
if (row.expires_at < Date.now()) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(row.sid);
return null;
}
// sliding renewal once past the halfway point
if (row.expires_at - Date.now() < (SESSION_DAYS / 2) * 86400_000) {
db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(
Date.now() + SESSION_DAYS * 86400_000,
row.sid
);
}
return { id: row.id, username: row.username };
}
export function destroySession(token: string | undefined): void {
if (token) db.prepare('DELETE FROM sessions WHERE id = ?').run(tokenId(token));
}
+195 -40
View File
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { PEAKS } from './peaks-seed';
import { assignMinzoom, scorePeak } from './peak-score';
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -11,8 +12,22 @@ db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'outdoor',
date TEXT,
@@ -27,31 +42,75 @@ db.exec(`
bounds TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_activities_user ON activities(user_id);
CREATE TABLE IF NOT EXISTS peaks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
elevation_m INTEGER NOT NULL,
osm_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
elevation_m REAL,
lat REAL NOT NULL,
lon REAL NOT NULL,
country TEXT NOT NULL,
region TEXT NOT NULL,
country TEXT,
region TEXT,
wikipedia TEXT,
wikidata TEXT,
score REAL NOT NULL DEFAULT 0,
minzoom INTEGER NOT NULL DEFAULT 14
);
CREATE INDEX IF NOT EXISTS idx_peaks_lat_zoom ON peaks(minzoom, lat, lon);
CREATE TABLE IF NOT EXISTS ascents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peak_id INTEGER NOT NULL REFERENCES peaks(id) ON DELETE CASCADE,
climbed_at TEXT,
activity_id INTEGER REFERENCES activities(id) ON DELETE SET NULL,
note TEXT
note TEXT,
UNIQUE (user_id, peak_id)
);
CREATE INDEX IF NOT EXISTS idx_ascents_user ON ascents(user_id);
`);
const seedPeak = db.prepare(`
INSERT OR IGNORE INTO peaks (name, elevation_m, lat, lon, country, region)
VALUES (@name, @elevation_m, @lat, @lon, @country, @region)
`);
db.transaction(() => {
for (const peak of PEAKS) seedPeak.run(peak);
})();
// Seed the catalog with the curated list while no OSM import has run.
// Seed rows use osm_id "seed:<name>"; scripts/import-peaks.js upgrades them
// to real OSM nodes (preserving ascents) and fills in the rest of the Alps.
const peakCount = (db.prepare('SELECT count(*) n FROM peaks').get() as { n: number }).n;
if (peakCount === 0) {
const seeded = PEAKS.map((p) => ({
...p,
lat: p.lat,
lon: p.lon,
wikipedia: 'seed',
score: 0,
minzoom: 14
}));
for (const p of seeded) p.score = scorePeak(p);
assignMinzoom(seeded);
const insert = db.prepare(`
INSERT INTO peaks (osm_id, name, elevation_m, lat, lon, country, region, score, minzoom)
VALUES (@osm_id, @name, @elevation_m, @lat, @lon, @country, @region, @score, @minzoom)
`);
db.transaction(() => {
for (const p of seeded) {
insert.run({
osm_id: `seed:${p.name}`,
name: p.name,
elevation_m: p.elevation_m,
lat: p.lat,
lon: p.lon,
country: p.country,
region: p.region,
score: p.score,
minzoom: p.minzoom
});
}
})();
}
export interface ActivityRow {
id: number;
user_id: number;
name: string;
type: string;
date: string | null;
@@ -69,55 +128,151 @@ export interface ActivityRow {
export interface PeakRow {
id: number;
osm_id: string;
name: string;
elevation_m: number;
elevation_m: number | null;
lat: number;
lon: number;
country: string;
region: string;
country: string | null;
region: string | null;
wikipedia: string | null;
wikidata: string | null;
score: number;
minzoom: number;
}
export interface AscentRow {
id: number;
user_id: number;
peak_id: number;
climbed_at: string | null;
activity_id: number | null;
note: string | null;
}
export function listActivities(): Omit<ActivityRow, 'points'>[] {
export function listActivities(userId: number): Omit<ActivityRow, 'points'>[] {
return db
.prepare(
`SELECT id, name, type, date, distance_m, duration_s, moving_s,
`SELECT id, user_id, name, type, date, distance_m, duration_s, moving_s,
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, bounds, created_at
FROM activities ORDER BY date DESC, id DESC`
FROM activities WHERE user_id = ? ORDER BY date DESC, id DESC`
)
.all() as Omit<ActivityRow, 'points'>[];
.all(userId) as Omit<ActivityRow, 'points'>[];
}
export function getActivity(id: number): ActivityRow | undefined {
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | undefined;
export function getActivity(id: number, userId: number): ActivityRow | undefined {
return db.prepare('SELECT * FROM activities WHERE id = ? AND user_id = ?').get(id, userId) as
| ActivityRow
| undefined;
}
export function deleteActivity(id: number): void {
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
export function deleteActivity(id: number, userId: number): void {
db.prepare('DELETE FROM activities WHERE id = ? AND user_id = ?').run(id, userId);
}
export function listPeaks(): PeakRow[] {
return db.prepare('SELECT * FROM peaks ORDER BY elevation_m DESC').all() as PeakRow[];
/** Notable peaks in a bounding box for a zoom level, with the user's climbed state. */
export function peaksInView(
userId: number,
bbox: { minLat: number; minLon: number; maxLat: number; maxLon: number },
zoom: number,
limit = 300
): (PeakRow & { climbed_at: string | null; ascent_activity_id: number | null })[] {
return db
.prepare(
`SELECT p.*, a.climbed_at, a.activity_id ascent_activity_id
FROM peaks p
LEFT JOIN ascents a ON a.peak_id = p.id AND a.user_id = @userId
WHERE p.minzoom <= @zoom
AND p.lat BETWEEN @minLat AND @maxLat
AND p.lon BETWEEN @minLon AND @maxLon
ORDER BY p.score DESC
LIMIT @limit`
)
.all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & {
climbed_at: string | null;
ascent_activity_id: number | null;
})[];
}
export function togglePeak(id: number, date?: string): PeakRow | undefined {
const peak = db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow | undefined;
if (!peak) return undefined;
if (peak.climbed_at) {
db.prepare('UPDATE peaks SET climbed_at = NULL, activity_id = NULL WHERE id = ?').run(id);
} else {
db.prepare('UPDATE peaks SET climbed_at = ? WHERE id = ?').run(
date ?? new Date().toISOString().slice(0, 10),
id
);
/** Peaks near a track's bounding box, for summit detection on upload. */
export function peaksNearBounds(
bounds: [[number, number], [number, number]],
marginDeg = 0.01
): PeakRow[] {
return db
.prepare(
`SELECT * FROM peaks
WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?`
)
.all(
bounds[0][0] - marginDeg,
bounds[1][0] + marginDeg,
bounds[0][1] - marginDeg,
bounds[1][1] + marginDeg
) as PeakRow[];
}
export function listAscents(
userId: number
): (AscentRow & { name: string; elevation_m: number | null; lat: number; lon: number })[] {
return db
.prepare(
`SELECT a.*, p.name, p.elevation_m, p.lat, p.lon
FROM ascents a JOIN peaks p ON p.id = a.peak_id
WHERE a.user_id = ?
ORDER BY p.elevation_m DESC`
)
.all(userId) as (AscentRow & {
name: string;
elevation_m: number | null;
lat: number;
lon: number;
})[];
}
export function countAscents(userId: number): number {
return (db.prepare('SELECT count(*) n FROM ascents WHERE user_id = ?').get(userId) as { n: number }).n;
}
/** Toggle an ascent; returns the new climbed_at (or null if now unclimbed). */
export function toggleAscent(userId: number, peakId: number): string | null {
const existing = db
.prepare('SELECT id FROM ascents WHERE user_id = ? AND peak_id = ?')
.get(userId, peakId) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM ascents WHERE id = ?').run(existing.id);
return null;
}
return db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow;
const date = new Date().toISOString().slice(0, 10);
db.prepare('INSERT INTO ascents (user_id, peak_id, climbed_at) VALUES (?, ?, ?)').run(
userId,
peakId,
date
);
return date;
}
export function markPeakClimbed(peakId: number, activityId: number, date: string | null): void {
db.prepare(
'UPDATE peaks SET climbed_at = ?, activity_id = ? WHERE id = ? AND climbed_at IS NULL'
).run(date ?? new Date().toISOString().slice(0, 10), activityId, peakId);
export function recordAscent(
userId: number,
peakId: number,
activityId: number,
date: string | null
): boolean {
const result = db
.prepare(
`INSERT INTO ascents (user_id, peak_id, climbed_at, activity_id)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, peak_id) DO NOTHING`
)
.run(userId, peakId, date ?? new Date().toISOString().slice(0, 10), activityId);
return result.changes > 0;
}
export function baggedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
return db
.prepare(
`SELECT p.*, a.climbed_at FROM ascents a JOIN peaks p ON p.id = a.peak_id
WHERE a.activity_id = ? AND a.user_id = ?`
)
.all(activityId, userId) as (PeakRow & { climbed_at: string | null })[];
}
+43
View File
@@ -0,0 +1,43 @@
// Notability scoring and zoom-level thinning for the peaks catalog.
// Plain JS so both the SvelteKit server and scripts/import-peaks.js can use it.
/**
* Higher score = more notable. Elevation is the base; encyclopedic presence
* and topographic prominence (when tagged in OSM) push famous peaks up.
* @param {{ elevation_m: number | null, wikipedia?: string | null, wikidata?: string | null, prominence_m?: number | null }} peak
* @returns {number}
*/
export function scorePeak(peak) {
let score = peak.elevation_m ?? 0;
if (peak.wikipedia) score += 800;
if (peak.wikidata) score += 400;
if (peak.prominence_m) score += Math.min(peak.prominence_m, 1500);
return score;
}
export const MAX_MINZOOM = 14;
/**
* Assign each peak the lowest zoom level at which it should appear.
* For every zoom 4..13 the map is divided into a grid (4 cells per tile
* width); the highest-scoring peak in a cell "wins" it and becomes visible
* from that zoom on. Everything that never wins shows from zoom 14.
* @template {{ lat: number, lon: number, score: number, minzoom?: number }} P
* @param {P[]} peaks
* @returns {P[]} the same array, each peak with `minzoom` set
*/
export function assignMinzoom(peaks) {
const sorted = [...peaks].sort((a, b) => b.score - a.score);
for (const peak of sorted) peak.minzoom = MAX_MINZOOM;
for (let z = 4; z <= 13; z++) {
const cell = 360 / (Math.pow(2, z) * 4);
const occupied = new Set();
for (const peak of sorted) {
const key = `${Math.floor(peak.lon / cell)}:${Math.floor(peak.lat / cell)}`;
if (occupied.has(key)) continue;
occupied.add(key);
if (peak.minzoom === MAX_MINZOOM) peak.minzoom = z;
}
}
return peaks;
}
+5
View File
@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return { user: locals.user };
};
+32 -1
View File
@@ -3,7 +3,7 @@
import '../app.css';
import { page } from '$app/state';
let { children } = $props();
let { children, data } = $props();
const links = [
{ href: '/', label: 'Dashboard' },
@@ -30,11 +30,17 @@
</svg>
Streba
</a>
{#if data.user}
<div class="nav-links">
{#each links as link (link.href)}
<a href={link.href} class:active={isActive(link.href)}>{link.label}</a>
{/each}
</div>
<form class="user" method="POST" action="/logout">
<span class="username">{data.user.username}</span>
<button class="logout" type="submit" title="Sign out">Sign out</button>
</form>
{/if}
</nav>
</header>
@@ -97,6 +103,31 @@
background: var(--accent-wash);
color: var(--accent-strong);
}
.user {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0;
}
.username {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
}
.logout {
border: none;
background: none;
padding: 0.3rem 0.5rem;
border-radius: 0.4rem;
color: var(--text-muted);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
.logout:hover {
background: var(--wash);
color: var(--text-primary);
}
main {
max-width: 1080px;
margin: 0 auto;
+12 -12
View File
@@ -1,16 +1,14 @@
import { db, listActivities, listPeaks } from '$lib/server/db';
import { countAscents, db, listActivities, listAscents } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
const activities = listActivities();
const peaks = listPeaks();
export const load: PageServerLoad = ({ locals }) => {
const userId = locals.user!.id;
const activities = listActivities(userId);
// all tracks, thinned for the overview map
const rows = db.prepare('SELECT id, name, points FROM activities').all() as {
id: number;
name: string;
points: string;
}[];
// all of the user's tracks, thinned for the overview map
const rows = db
.prepare('SELECT id, name, points FROM activities WHERE user_id = ?')
.all(userId) as { id: number; name: string; points: string }[];
const tracks = rows.map((row) => {
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
const step = Math.max(1, Math.floor(points.length / 300));
@@ -20,6 +18,8 @@ export const load: PageServerLoad = () => {
return { id: row.id, name: row.name, latlngs };
});
const ascents = listAscents(userId);
return {
activities,
tracks,
@@ -29,7 +29,7 @@ export const load: PageServerLoad = () => {
elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_m, 0),
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
},
peaksClimbed: peaks.filter((p) => p.climbed_at).length,
peaksTotal: peaks.length
peaksClimbed: countAscents(userId),
highestAscent: ascents[0] ?? null
};
};
+12 -16
View File
@@ -48,12 +48,15 @@
<div class="card peak-progress">
<div class="meter-nums">
<span class="big">{data.peaksClimbed}</span>
<span class="of">of {data.peaksTotal} peaks climbed</span>
<span class="of">peak{data.peaksClimbed === 1 ? '' : 's'} climbed</span>
</div>
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={data.peaksTotal} aria-valuenow={data.peaksClimbed} aria-label="Peaks climbed">
<div class="fill" style:width="{(data.peaksClimbed / data.peaksTotal) * 100}%"></div>
</div>
<a class="btn ghost" href="/peaks">Open the checklist</a>
{#if data.highestAscent}
<p class="highest">
Highest so far: <strong>{data.highestAscent.name}</strong>
({Math.round(data.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)
</p>
{/if}
<a class="btn ghost" href="/peaks">Open the peak map</a>
</div>
</section>
</div>
@@ -115,16 +118,9 @@
font-size: 0.9rem;
margin-left: 0.4rem;
}
.meter {
width: 100%;
height: 8px;
border-radius: 999px;
background: var(--accent-track);
}
.fill {
height: 100%;
border-radius: 999px;
background: var(--accent);
min-width: 2px;
.highest {
margin: 0;
font-size: 0.88rem;
color: var(--text-secondary);
}
</style>
+2 -2
View File
@@ -1,6 +1,6 @@
import { listActivities } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
return { activities: listActivities() };
export const load: PageServerLoad = ({ locals }) => {
return { activities: listActivities(locals.user!.id) };
};
+7 -10
View File
@@ -1,11 +1,10 @@
import { error, redirect } from '@sveltejs/kit';
import { db, deleteActivity, getActivity } from '$lib/server/db';
import { baggedPeaks, deleteActivity, getActivity } from '$lib/server/db';
import { haversine } from '$lib/server/gpx';
import type { Actions, PageServerLoad } from './$types';
import type { PeakRow } from '$lib/server/db';
export const load: PageServerLoad = ({ params }) => {
const activity = getActivity(Number(params.id));
export const load: PageServerLoad = ({ params, locals }) => {
const activity = getActivity(Number(params.id), locals.user!.id);
if (!activity) error(404, 'Activity not found');
const points = JSON.parse(activity.points) as {
@@ -27,9 +26,7 @@ export const load: PageServerLoad = ({ params }) => {
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
}
const bagged = db
.prepare('SELECT * FROM peaks WHERE activity_id = ?')
.all(activity.id) as PeakRow[];
const bagged = baggedPeaks(activity.id, locals.user!.id);
return {
activity: { ...activity, points: undefined },
@@ -38,7 +35,7 @@ export const load: PageServerLoad = ({ params }) => {
bagged: bagged.map((p) => ({
id: p.id,
name: p.name,
elevation_m: p.elevation_m,
elevation_m: Math.round(p.elevation_m ?? 0),
lat: p.lat,
lon: p.lon,
climbed: true
@@ -47,8 +44,8 @@ export const load: PageServerLoad = ({ params }) => {
};
export const actions: Actions = {
delete: async ({ params }) => {
deleteActivity(Number(params.id));
delete: async ({ params, locals }) => {
deleteActivity(Number(params.id), locals.user!.id);
redirect(303, '/activities');
}
};
+28
View File
@@ -0,0 +1,28 @@
import { error, json } from '@sveltejs/kit';
import { peaksInView } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ url, locals }) => {
const num = (name: string) => {
const value = parseFloat(url.searchParams.get(name) ?? '');
if (!Number.isFinite(value)) error(400, `Missing or invalid "${name}"`);
return value;
};
const bbox = {
minLat: num('minLat'),
minLon: num('minLon'),
maxLat: num('maxLat'),
maxLon: num('maxLon')
};
const zoom = num('zoom');
const peaks = peaksInView(locals.user!.id, bbox, zoom).map((p) => ({
id: p.id,
name: p.name,
elevation_m: p.elevation_m,
lat: p.lat,
lon: p.lon,
climbed_at: p.climbed_at
}));
return json({ peaks });
};
+5 -6
View File
@@ -1,9 +1,8 @@
import { error, json } from '@sveltejs/kit';
import { togglePeak } from '$lib/server/db';
import { json } from '@sveltejs/kit';
import { toggleAscent } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = ({ params }) => {
const peak = togglePeak(Number(params.id));
if (!peak) error(404, 'Peak not found');
return json(peak);
export const POST: RequestHandler = ({ params, locals }) => {
const climbed_at = toggleAscent(locals.user!.id, Number(params.id));
return json({ id: Number(params.id), climbed_at });
};
+24
View File
@@ -0,0 +1,24 @@
import { fail, redirect } from '@sveltejs/kit';
import { createSession, findUser, verifyPassword } from '$lib/server/auth';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');
const user = username ? findUser(username) : undefined;
if (!user || !verifyPassword(password, user.password_hash)) {
return fail(400, { username, error: 'Wrong username or password.' });
}
cookies.set('session', createSession(user.id), {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 86400
});
redirect(303, '/');
}
};
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
let { form } = $props();
</script>
<svelte:head>
<title>Sign in · Streba</title>
</svelte:head>
<div class="auth card">
<h1>Welcome back</h1>
<p class="page-sub">Sign in to your Streba account.</p>
<form method="POST">
<label>
Username
<input name="username" required autocomplete="username" value={form?.username ?? ''} />
</label>
<label>
Password
<input name="password" type="password" required autocomplete="current-password" />
</label>
{#if form?.error}<p class="error">{form.error}</p>{/if}
<button class="btn" type="submit">Sign in</button>
</form>
<p class="alt">No account yet? <a href="/signup">Sign up</a></p>
</div>
<style>
.auth {
max-width: 380px;
margin: 3rem auto 0;
padding: 2rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
}
input {
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--page);
color: var(--text-primary);
font: inherit;
}
input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: transparent;
}
.error {
color: var(--critical);
font-size: 0.85rem;
margin: 0;
}
.btn {
justify-content: center;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
color: var(--text-secondary);
}
.alt a {
color: var(--accent-strong);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import { destroySession } from '$lib/server/auth';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = ({ cookies }) => {
destroySession(cookies.get('session'));
cookies.delete('session', { path: '/' });
redirect(303, '/login');
};
+3 -3
View File
@@ -1,6 +1,6 @@
import { listPeaks } from '$lib/server/db';
import { listAscents } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
return { peaks: listPeaks() };
export const load: PageServerLoad = ({ locals }) => {
return { ascents: listAscents(locals.user!.id) };
};
+123 -63
View File
@@ -1,49 +1,92 @@
<script lang="ts">
import Map from '$lib/components/Map.svelte';
import Map, { type Viewport } from '$lib/components/Map.svelte';
import { fmtDate } from '$lib/format';
let { data } = $props();
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
let peaks = $state(data.peaks);
$effect(() => {
peaks = data.peaks;
});
let filter: 'all' | 'climbed' | 'remaining' = $state('all');
interface ViewPeak {
id: number;
name: string;
elevation_m: number | null;
lat: number;
lon: number;
climbed_at: string | null;
}
const climbed = $derived(peaks.filter((p) => p.climbed_at).length);
const highest = $derived(peaks.filter((p) => p.climbed_at).sort((a, b) => b.elevation_m - a.elevation_m)[0]);
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
let ascents = $state(data.ascents);
$effect(() => {
ascents = data.ascents;
});
let viewPeaks: ViewPeak[] = $state([]);
let tab: 'view' | 'mine' = $state('view');
let loading = $state(false);
const highest = $derived(ascents[0] ?? null);
const mapPeaks = $derived(
peaks.map((p) => ({
viewPeaks.map((p) => ({
id: p.id,
name: p.name,
elevation_m: p.elevation_m,
elevation_m: Math.round(p.elevation_m ?? 0),
lat: p.lat,
lon: p.lon,
climbed: !!p.climbed_at
}))
);
const regions = $derived.by(() => {
const visible = peaks.filter((p) =>
filter === 'all' ? true : filter === 'climbed' ? !!p.climbed_at : !p.climbed_at
);
const byRegion = new globalThis.Map<string, typeof visible>();
for (const p of visible) {
if (!byRegion.has(p.region)) byRegion.set(p.region, []);
byRegion.get(p.region)!.push(p);
let fetchTimer: ReturnType<typeof setTimeout> | undefined;
let lastView: Viewport | undefined;
function onviewport(view: Viewport) {
lastView = view;
clearTimeout(fetchTimer);
fetchTimer = setTimeout(loadPeaks, 250);
}
return [...byRegion.entries()].sort(
(a, b) => Math.max(...b[1].map((p) => p.elevation_m)) - Math.max(...a[1].map((p) => p.elevation_m))
);
async function loadPeaks() {
if (!lastView) return;
loading = true;
const params = new URLSearchParams({
minLat: String(lastView.minLat),
minLon: String(lastView.minLon),
maxLat: String(lastView.maxLat),
maxLon: String(lastView.maxLon),
zoom: String(Math.round(lastView.zoom))
});
const res = await fetch(`/api/peaks?${params}`);
if (res.ok) viewPeaks = (await res.json()).peaks;
loading = false;
}
async function toggle(id: number) {
const res = await fetch(`/api/peaks/${id}/toggle`, { method: 'POST' });
if (!res.ok) return;
const updated = await res.json();
peaks = peaks.map((p) => (p.id === id ? updated : p));
const { climbed_at } = await res.json();
viewPeaks = viewPeaks.map((p) => (p.id === id ? { ...p, climbed_at } : p));
if (climbed_at === null) {
ascents = ascents.filter((a) => a.peak_id !== id);
} else {
const peak = viewPeaks.find((p) => p.id === id);
if (peak) {
ascents = [
...ascents,
{
id: -id,
user_id: 0,
peak_id: id,
climbed_at,
activity_id: null,
note: null,
name: peak.name,
elevation_m: peak.elevation_m,
lat: peak.lat,
lon: peak.lon
}
].sort((a, b) => (b.elevation_m ?? 0) - (a.elevation_m ?? 0));
}
}
}
</script>
@@ -53,38 +96,34 @@
<h1>Alpine peaks</h1>
<p class="page-sub">
{climbed} of {peaks.length} peaks climbed{#if highest}&nbsp;· highest so far: {highest.name} ({highest.elevation_m.toLocaleString('en-US')} m){/if}.
Click a peak on the map or in the list to cross it off.
{ascents.length} peak{ascents.length === 1 ? '' : 's'} climbed{#if highest}&nbsp;· highest so far: {highest.name}
({Math.round(highest.elevation_m ?? 0).toLocaleString('en-US')} m){/if}.
Zoom in to reveal less prominent summits; click a peak to cross it off.
</p>
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={peaks.length} aria-valuenow={climbed} aria-label="Peaks climbed">
<div class="fill" style:width="{(climbed / peaks.length) * 100}%"></div>
</div>
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} {onviewport} />
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} />
<div class="filters" role="group" aria-label="Filter peaks">
{#each [['all', 'All'], ['remaining', 'To climb'], ['climbed', 'Climbed']] as [key, label] (key)}
<button
class="filter-btn"
class:on={filter === key}
onclick={() => (filter = key as typeof filter)}
>
{label}
<div class="tabs" role="group" aria-label="Peak lists">
<button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}>
In view {loading ? '…' : `(${viewPeaks.length})`}
</button>
<button class="filter-btn" class:on={tab === 'mine'} onclick={() => (tab = 'mine')}>
My ascents ({ascents.length})
</button>
{/each}
</div>
{#each regions as [region, list] (region)}
<h2>{region}</h2>
{#if tab === 'view'}
{#if viewPeaks.length === 0}
<div class="card empty">No notable peaks in this view - try panning to the Alps or zooming in.</div>
{:else}
<div class="card grid">
{#each list as peak (peak.id)}
{#each viewPeaks as peak (peak.id)}
<button class="peak" class:done={peak.climbed_at} onclick={() => toggle(peak.id)}>
<span class="check" aria-hidden="true">{peak.climbed_at ? '✓' : ''}</span>
<span class="info">
<span class="name">{peak.name}</span>
<span class="sub">
{peak.elevation_m.toLocaleString('en-US')} m · {peak.country}
{peak.elevation_m ? `${Math.round(peak.elevation_m).toLocaleString('en-US')} m` : 'elevation unknown'}
{#if peak.climbed_at}
· climbed {fmtDate(peak.climbed_at)}
{/if}
@@ -93,28 +132,41 @@
</button>
{/each}
</div>
{/if}
{:else if ascents.length === 0}
<div class="card empty">
Nothing climbed yet - click a peak on the map, or upload a GPX track that crosses a summit.
</div>
{:else}
<div class="card empty-filter">Nothing here - try another filter.</div>
{/each}
<div class="card grid">
{#each ascents as ascent (ascent.peak_id)}
<div class="peak done">
<button
class="check"
title="Remove ascent"
aria-label="Remove ascent of {ascent.name}"
onclick={() => toggle(ascent.peak_id)}>✓</button
>
<span class="info">
<span class="name">{ascent.name}</span>
<span class="sub">
{ascent.elevation_m ? `${Math.round(ascent.elevation_m).toLocaleString('en-US')} m` : ''}
· climbed {fmtDate(ascent.climbed_at)}
{#if ascent.activity_id}
· <a href="/activities/{ascent.activity_id}">view activity</a>
{/if}
</span>
</span>
</div>
{/each}
</div>
{/if}
<style>
.meter {
width: 100%;
height: 8px;
border-radius: 999px;
background: var(--accent-track);
margin-bottom: 1.25rem;
}
.fill {
height: 100%;
border-radius: 999px;
background: var(--accent);
min-width: 2px;
}
.filters {
.tabs {
display: flex;
gap: 0.4rem;
margin-top: 1.5rem;
margin-top: 1.25rem;
}
.filter-btn {
padding: 0.35rem 0.9rem;
@@ -138,6 +190,7 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
margin-top: 0.75rem;
}
.peak {
display: flex;
@@ -159,14 +212,18 @@
flex-shrink: 0;
width: 22px;
height: 22px;
padding: 0;
border-radius: 6px;
border: 1.5px solid var(--baseline);
background: none;
display: flex;
align-items: center;
justify-content: center;
font: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
cursor: pointer;
transition: background 120ms, border-color 120ms;
}
.peak.done .check {
@@ -192,8 +249,11 @@
font-size: 0.78rem;
color: var(--text-muted);
}
.empty-filter {
margin-top: 1rem;
.sub a {
color: var(--accent-strong);
}
.empty {
margin-top: 0.75rem;
padding: 1.5rem;
text-align: center;
color: var(--text-secondary);
+33
View File
@@ -0,0 +1,33 @@
import { fail, redirect } from '@sveltejs/kit';
import { createSession, createUser, findUser } from '$lib/server/auth';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');
if (!/^[a-zA-Z0-9_.-]{3,30}$/.test(username)) {
return fail(400, {
username,
error: 'Username must be 3-30 characters (letters, digits, _ . -).'
});
}
if (password.length < 8) {
return fail(400, { username, error: 'Password must be at least 8 characters.' });
}
if (findUser(username)) {
return fail(400, { username, error: 'That username is taken.' });
}
const user = createUser(username, password);
cookies.set('session', createSession(user.id), {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 86400
});
redirect(303, '/');
}
};
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
let { form } = $props();
</script>
<svelte:head>
<title>Sign up · Streba</title>
</svelte:head>
<div class="auth card">
<h1>Create account</h1>
<p class="page-sub">Track your activities and bag Alpine peaks.</p>
<form method="POST">
<label>
Username
<input
name="username"
required
minlength="3"
maxlength="30"
pattern="[a-zA-Z0-9_.\-]+"
autocomplete="username"
value={form?.username ?? ''}
/>
</label>
<label>
Password
<input name="password" type="password" required minlength="8" autocomplete="new-password" />
</label>
{#if form?.error}<p class="error">{form.error}</p>{/if}
<button class="btn" type="submit">Sign up</button>
</form>
<p class="alt">Already have an account? <a href="/login">Sign in</a></p>
</div>
<style>
.auth {
max-width: 380px;
margin: 3rem auto 0;
padding: 2rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
}
input {
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--page);
color: var(--text-primary);
font: inherit;
}
input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: transparent;
}
.error {
color: var(--critical);
font-size: 0.85rem;
margin: 0;
}
.btn {
justify-content: center;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
color: var(--text-secondary);
}
.alt a {
color: var(--accent-strong);
}
</style>
+13 -12
View File
@@ -1,10 +1,11 @@
import { fail } from '@sveltejs/kit';
import { db, listPeaks, markPeakClimbed } from '$lib/server/db';
import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
import { matchPeaks, parseGpx } from '$lib/server/gpx';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
default: async ({ request, locals }) => {
const userId = locals.user!.id;
const form = await request.formData();
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.' });
@@ -14,10 +15,10 @@ export const actions: Actions = {
const insert = db.prepare(`
INSERT INTO activities
(name, type, date, distance_m, duration_s, moving_s,
(user_id, name, type, date, distance_m, duration_s, moving_s,
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, points, bounds)
VALUES
(@name, @type, @date, @distance_m, @duration_s, @moving_s,
(@user_id, @name, @type, @date, @distance_m, @duration_s, @moving_s,
@elev_gain_m, @elev_loss_m, @elev_min_m, @elev_max_m, @points, @bounds)
`);
@@ -25,6 +26,7 @@ export const actions: Actions = {
try {
const gpx = parseGpx(await file.text());
const result = insert.run({
user_id: userId,
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''),
type: gpx.type ?? 'outdoor',
date: gpx.date,
@@ -40,15 +42,14 @@ export const actions: Actions = {
});
const activityId = Number(result.lastInsertRowid);
const unclimbed = listPeaks().filter((p) => !p.climbed_at);
const bagged = matchPeaks(gpx.points, unclimbed);
for (const peak of bagged) markPeakClimbed(peak.id, activityId, gpx.date);
const nearby = peaksNearBounds(gpx.bounds);
const summited = matchPeaks(gpx.points, nearby);
const newPeaks: string[] = [];
for (const peak of summited) {
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
}
uploaded.push({
id: activityId,
name: gpx.name ?? file.name,
newPeaks: bagged.map((p) => p.name)
});
uploaded.push({ id: activityId, name: gpx.name ?? file.name, newPeaks });
} catch (err) {
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
}