From b17f56b19483d1f5c9c835c9c9b182568e7c7118 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Wed, 22 Jul 2026 11:10:59 +0200 Subject: [PATCH] add user accounts and OSM peak catalog with zoom-based notability --- README.md | 28 ++- package-lock.json | 11 + package.json | 1 + scripts/import-peaks.js | 137 ++++++++++++ src/app.d.ts | 8 +- src/hooks.server.ts | 19 ++ src/lib/components/Map.svelte | 28 ++- src/lib/server/auth.ts | 77 +++++++ src/lib/server/db.ts | 235 ++++++++++++++++---- src/lib/server/peak-score.js | 43 ++++ src/routes/+layout.server.ts | 5 + src/routes/+layout.svelte | 43 +++- src/routes/+page.server.ts | 24 +- src/routes/+page.svelte | 28 +-- src/routes/activities/+page.server.ts | 4 +- src/routes/activities/[id]/+page.server.ts | 17 +- src/routes/api/peaks/+server.ts | 28 +++ src/routes/api/peaks/[id]/toggle/+server.ts | 11 +- src/routes/login/+page.server.ts | 24 ++ src/routes/login/+page.svelte | 75 +++++++ src/routes/logout/+server.ts | 9 + src/routes/peaks/+page.server.ts | 6 +- src/routes/peaks/+page.svelte | 208 +++++++++++------ src/routes/signup/+page.server.ts | 33 +++ src/routes/signup/+page.svelte | 83 +++++++ src/routes/upload/+page.server.ts | 25 ++- 26 files changed, 1019 insertions(+), 191 deletions(-) create mode 100644 scripts/import-peaks.js create mode 100644 src/hooks.server.ts create mode 100644 src/lib/server/auth.ts create mode 100644 src/lib/server/peak-score.js create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/api/peaks/+server.ts create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/logout/+server.ts create mode 100644 src/routes/signup/+page.server.ts create mode 100644 src/routes/signup/+page.svelte diff --git a/README.md b/README.md index b608726..0ed7487 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 058fd4f..b27f011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8228f1d..65e1e53 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/import-peaks.js b/scripts/import-peaks.js new file mode 100644 index 0000000..b761ffc --- /dev/null +++ b/scripts/import-peaks.js @@ -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(', ')); diff --git a/src/app.d.ts b/src/app.d.ts index 6285566..30f609c 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,11 @@ +import type { User } from '$lib/server/auth'; + declare global { - namespace App {} + namespace App { + interface Locals { + user: User | null; + } + } } export {}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..4e97c81 --- /dev/null +++ b/src/hooks.server.ts @@ -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); +}; diff --git a/src/lib/components/Map.svelte b/src/lib/components/Map.svelte index 4ca8b80..2315f49 100644 --- a/src/lib/components/Map.svelte +++ b/src/lib/components/Map.svelte @@ -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]); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..48e5fc9 --- /dev/null +++ b/src/lib/server/auth.ts @@ -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)); +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index ec57a14..4b3ca2d 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -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:"; 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[] { +export function listActivities(userId: number): Omit[] { 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[]; + .all(userId) as Omit[]; } -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 })[]; } diff --git a/src/lib/server/peak-score.js b/src/lib/server/peak-score.js new file mode 100644 index 0000000..377eea3 --- /dev/null +++ b/src/lib/server/peak-score.js @@ -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; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..45f4fcc --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ locals }) => { + return { user: locals.user }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6369a7d..613af10 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 @@ Streba - + {#if data.user} + +
+ {data.user.username} + +
+ {/if} @@ -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; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 14babb4..fb2904f 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -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 }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2632144..55964c4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -48,12 +48,15 @@
{data.peaksClimbed} - of {data.peaksTotal} peaks climbed + peak{data.peaksClimbed === 1 ? '' : 's'} climbed
-
-
-
- Open the checklist + {#if data.highestAscent} +

+ Highest so far: {data.highestAscent.name} + ({Math.round(data.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m) +

+ {/if} + Open the peak map
@@ -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); } diff --git a/src/routes/activities/+page.server.ts b/src/routes/activities/+page.server.ts index 683dbe1..08c79fd 100644 --- a/src/routes/activities/+page.server.ts +++ b/src/routes/activities/+page.server.ts @@ -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) }; }; diff --git a/src/routes/activities/[id]/+page.server.ts b/src/routes/activities/[id]/+page.server.ts index d4a4968..f88895d 100644 --- a/src/routes/activities/[id]/+page.server.ts +++ b/src/routes/activities/[id]/+page.server.ts @@ -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'); } }; diff --git a/src/routes/api/peaks/+server.ts b/src/routes/api/peaks/+server.ts new file mode 100644 index 0000000..c49523e --- /dev/null +++ b/src/routes/api/peaks/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/peaks/[id]/toggle/+server.ts b/src/routes/api/peaks/[id]/toggle/+server.ts index b5a63ca..0e2f116 100644 --- a/src/routes/api/peaks/[id]/toggle/+server.ts +++ b/src/routes/api/peaks/[id]/toggle/+server.ts @@ -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 }); }; diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..197af6e --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -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, '/'); + } +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..47949a1 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,75 @@ + + + + Sign in · Streba + + +
+

Welcome back

+

Sign in to your Streba account.

+
+ + + {#if form?.error}

{form.error}

{/if} + +
+

No account yet? Sign up

+
+ + diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts new file mode 100644 index 0000000..d21133c --- /dev/null +++ b/src/routes/logout/+server.ts @@ -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'); +}; diff --git a/src/routes/peaks/+page.server.ts b/src/routes/peaks/+page.server.ts index ca14513..0365c88 100644 --- a/src/routes/peaks/+page.server.ts +++ b/src/routes/peaks/+page.server.ts @@ -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) }; }; diff --git a/src/routes/peaks/+page.svelte b/src/routes/peaks/+page.svelte index 0f7ba2f..7dc209f 100644 --- a/src/routes/peaks/+page.svelte +++ b/src/routes/peaks/+page.svelte @@ -1,49 +1,92 @@ @@ -53,68 +96,77 @@

Alpine peaks

- {climbed} of {peaks.length} peaks climbed{#if highest} · 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} · 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.

-
-
+ + +
+ +
- - -
- {#each [['all', 'All'], ['remaining', 'To climb'], ['climbed', 'Climbed']] as [key, label] (key)} - - {/each} -
- -{#each regions as [region, list] (region)} -

{region}

+{#if tab === 'view'} + {#if viewPeaks.length === 0} +
No notable peaks in this view - try panning to the Alps or zooming in.
+ {:else} +
+ {#each viewPeaks as peak (peak.id)} + + {/each} +
+ {/if} +{:else if ascents.length === 0} +
+ Nothing climbed yet - click a peak on the map, or upload a GPX track that crosses a summit. +
+{:else}
- {#each list as peak (peak.id)} - - {peak.name} + {ascent.name} - {peak.elevation_m.toLocaleString('en-US')} m · {peak.country} - {#if peak.climbed_at} - · climbed {fmtDate(peak.climbed_at)} + {ascent.elevation_m ? `${Math.round(ascent.elevation_m).toLocaleString('en-US')} m` : ''} + · climbed {fmtDate(ascent.climbed_at)} + {#if ascent.activity_id} + · view activity {/if} - +
{/each}
-{:else} -
Nothing here - try another filter.
-{/each} +{/if} diff --git a/src/routes/upload/+page.server.ts b/src/routes/upload/+page.server.ts index 217e6cb..31daea2 100644 --- a/src/routes/upload/+page.server.ts +++ b/src/routes/upload/+page.server.ts @@ -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'}`); }