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'; 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 }); export const db = new Database(path.join(DATA_DIR, 'streba.db')); 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, distance_m REAL NOT NULL, duration_s REAL, moving_s REAL, elev_gain_m REAL NOT NULL DEFAULT 0, elev_loss_m REAL NOT NULL DEFAULT 0, elev_min_m REAL, elev_max_m REAL, points TEXT NOT NULL, 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, osm_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL, elevation_m REAL, lat REAL NOT NULL, lon REAL 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 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, 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, UNIQUE (user_id, peak_id) ); CREATE INDEX IF NOT EXISTS idx_ascents_user ON ascents(user_id); `); // lightweight migrations for columns added after the initial schema const userCols = (db.prepare('PRAGMA table_info(users)').all() as { name: string }[]).map( (c) => c.name ); for (const col of ['bio', 'location', 'avatar']) { if (!userCols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} TEXT`); } // 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; distance_m: number; duration_s: number | null; moving_s: number | null; elev_gain_m: number; elev_loss_m: number; elev_min_m: number | null; elev_max_m: number | null; points: string; bounds: string; created_at: string; } export interface PeakRow { id: number; osm_id: string; name: string; elevation_m: number | null; lat: number; lon: number; 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(userId: number): Omit[] { return db .prepare( `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 WHERE user_id = ? ORDER BY date DESC, id DESC` ) .all(userId) as Omit[]; } export function getActivity(id: number): (ActivityRow & { username: string }) | undefined { return db .prepare( `SELECT a.*, u.username FROM activities a JOIN users u ON u.id = a.user_id WHERE a.id = ?` ) .get(id) as (ActivityRow & { username: string }) | undefined; } /** All users' activities, newest first, with uploader attribution. */ export function listAllActivities(): (Omit & { username: string })[] { return db .prepare( `SELECT a.id, a.user_id, a.name, a.type, a.date, a.distance_m, a.duration_s, a.moving_s, a.elev_gain_m, a.elev_loss_m, a.elev_min_m, a.elev_max_m, a.bounds, a.created_at, u.username FROM activities a JOIN users u ON u.id = a.user_id ORDER BY a.date DESC, a.id DESC` ) .all() as (Omit & { username: string })[]; } export interface ProfileRow { id: number; username: string; created_at: string; bio: string | null; location: string | null; avatar: string | null; } export function getProfile(username: string): ProfileRow | undefined { return db .prepare('SELECT id, username, created_at, bio, location, avatar FROM users WHERE username = ?') .get(username) as ProfileRow | undefined; } export function updateProfile( userId: number, fields: { bio: string | null; location: string | null; avatar?: string } ): void { if (fields.avatar !== undefined) { db.prepare('UPDATE users SET bio = ?, location = ?, avatar = ? WHERE id = ?').run( fields.bio, fields.location, fields.avatar, userId ); } else { db.prepare('UPDATE users SET bio = ?, location = ? WHERE id = ?').run( fields.bio, fields.location, userId ); } } export function userStats(userId: number): { activities: number; distance_m: number; elev_gain_m: number; moving_s: number; } { return db .prepare( `SELECT count(*) activities, coalesce(sum(distance_m), 0) distance_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 = ?` ) .get(userId) as { activities: number; distance_m: number; elev_gain_m: number; moving_s: number }; } export function communityTotals(): { users: number; activities: number; distance_m: number; elev_gain_m: number; } { return db .prepare( `SELECT (SELECT count(*) FROM users) users, count(*) activities, coalesce(sum(distance_m), 0) distance_m, coalesce(sum(${COUNTED_GAIN}), 0) elev_gain_m FROM activities` ) .get() as { users: number; activities: number; distance_m: number; elev_gain_m: number }; } export function deleteActivity(id: number, userId: number): void { db.prepare('DELETE FROM activities WHERE id = ? AND user_id = ?').run(id, userId); } /** 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 = 1000 ): (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 OR a.climbed_at IS NOT NULL) AND p.lat BETWEEN @minLat AND @maxLat AND p.lon BETWEEN @minLon AND @maxLon ORDER BY (a.climbed_at IS NOT NULL) DESC, p.score DESC LIMIT @limit` ) .all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & { climbed_at: string | null; ascent_activity_id: number | null; })[]; } /** 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; } 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 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 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 & { 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 & { 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( `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 })[]; }