add user accounts and OSM peak catalog with zoom-based notability
This commit is contained in:
+195
-40
@@ -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 })[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user