580 lines
17 KiB
TypeScript
580 lines
17 KiB
TypeScript
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:<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;
|
|
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<ActivityRow, 'points'>[] {
|
|
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<ActivityRow, 'points'>[];
|
|
}
|
|
|
|
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<ActivityRow, 'points'> & { 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<ActivityRow, 'points'> & { 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<SegmentRow, 'points'> & {
|
|
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<SegmentRow, 'points'> & {
|
|
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 })[];
|
|
}
|