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
+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 })[];
}