initial version of streba 2

This commit is contained in:
Vincent van der Wal
2026-07-22 09:24:18 +02:00
commit 196f30521c
31 changed files with 5056 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.claude/
.mcp.json
.svelte-kit/
build/
dist/
data/
*.log
.DS_Store
.env
.env.*
!.env.example
vite.config.ts.timestamp-*
+44
View File
@@ -0,0 +1,44 @@
# Streba
The GPX analyser, reborn. A single-user, self-hosted web app for analysing GPX
tracks and bagging Alpine peaks.
- **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.
## Stack
- [SvelteKit](https://svelte.dev/docs/kit) (Svelte 5, TypeScript) with the Node adapter
- SQLite via `better-sqlite3` - the database lives in `data/streba.db`, no
server setup needed
- [MapLibre GL](https://maplibre.org) for maps
- Hand-rolled SVG elevation charts
## Development
```sh
npm install
npm run dev
```
## Production
```sh
npm run build
node build
```
Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to
`./data`).
## 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.
+2942
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "streba",
"version": "2.0.0",
"private": true,
"description": "Streba - the GPX analyser, reborn",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"fast-xml-parser": "^5.10.1",
"maplibre-gl": "^5.24.0"
},
"devDependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13",
"svelte": "^5.56.7",
"svelte-check": "^4.7.3",
"typescript": "^6.0.3",
"vite": "^8.1.5"
}
}
+120
View File
@@ -0,0 +1,120 @@
:root {
color-scheme: light;
--page: #f9f9f7;
--surface-1: #fcfcfb;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #898781;
--grid: #e1e0d9;
--baseline: #c3c2b7;
--border: rgba(11, 11, 11, 0.1);
--wash: rgba(11, 11, 11, 0.05);
--accent: #2a78d6;
--accent-strong: #1c5cab;
--accent-wash: rgba(42, 120, 214, 0.1);
--accent-track: #cde2fb;
--good: #0ca30c;
--good-text: #006300;
--critical: #d03b3b;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--page: #0d0d0d;
--surface-1: #1a1a19;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--grid: #2c2c2a;
--baseline: #383835;
--border: rgba(255, 255, 255, 0.1);
--wash: rgba(255, 255, 255, 0.06);
--accent: #3987e5;
--accent-strong: #6da7ec;
--accent-wash: rgba(57, 135, 229, 0.16);
--accent-track: #184f95;
--good: #0ca30c;
--good-text: #0ca30c;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--page);
color: var(--text-primary);
font-family: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.25rem;
}
h2 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 2rem 0 0.75rem;
}
.page-sub {
color: var(--text-secondary);
font-size: 0.925rem;
margin: 0 0 1.5rem;
}
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 0.875rem;
overflow: hidden;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 0.55rem;
border: 1px solid transparent;
background: var(--accent);
color: #fff;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: filter 120ms;
}
.btn:hover {
filter: brightness(1.08);
}
.btn.ghost {
background: transparent;
color: var(--text-secondary);
border-color: var(--border);
}
.btn.ghost:hover {
background: var(--wash);
color: var(--text-primary);
filter: none;
}
.btn.danger {
background: transparent;
color: var(--critical);
border-color: var(--border);
}
.btn.danger:hover {
background: rgba(208, 59, 59, 0.08);
filter: none;
}
+5
View File
@@ -0,0 +1,5 @@
declare global {
namespace App {}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
let {
activity
}: {
activity: {
id: number;
name: string;
type: string;
date: string | null;
distance_m: number;
moving_s: number | null;
duration_s: number | null;
elev_gain_m: number;
};
} = $props();
</script>
<a class="item" href="/activities/{activity.id}">
<div class="head">
<span class="name">{activity.name}</span>
<span class="type">{activity.type}</span>
</div>
<div class="meta">
<span>{fmtDate(activity.date)}</span>
<span class="dot">·</span>
<span>{fmtDistance(activity.distance_m)}</span>
<span class="dot">·</span>
<span>{fmtElevation(activity.elev_gain_m)}</span>
<span class="dot">·</span>
<span>{fmtDuration(activity.moving_s ?? activity.duration_s)}</span>
</div>
</a>
<style>
.item {
display: block;
padding: 0.85rem 1.15rem;
text-decoration: none;
color: inherit;
transition: background 120ms;
}
.item:hover {
background: var(--wash);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.name {
font-weight: 600;
font-size: 0.95rem;
}
.type {
font-size: 0.75rem;
font-weight: 500;
color: var(--accent-strong);
background: var(--accent-wash);
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: capitalize;
flex-shrink: 0;
}
.meta {
display: flex;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--text-secondary);
margin-top: 0.15rem;
}
.dot {
color: var(--text-muted);
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
let { profile }: { profile: { d: number; ele: number }[] } = $props();
let width = $state(720);
const height = 220;
const pad = { top: 12, right: 12, bottom: 24, left: 46 };
const totalDist = $derived(profile.length ? profile[profile.length - 1].d : 1);
const eleMin = $derived(Math.min(...profile.map((p) => p.ele)));
const eleMax = $derived(Math.max(...profile.map((p) => p.ele)));
const yTicks = $derived.by(() => {
const span = Math.max(eleMax - eleMin, 10);
const step = [10, 20, 50, 100, 200, 250, 500, 1000].find((s) => span / s <= 5) ?? 1000;
const start = Math.ceil(eleMin / step) * step;
const ticks: number[] = [];
for (let v = start; v <= eleMax; v += step) ticks.push(v);
return ticks;
});
const xTicks = $derived.by(() => {
const km = totalDist / 1000;
const step = [0.5, 1, 2, 5, 10, 20, 50, 100].find((s) => km / s <= 6) ?? 100;
const ticks: number[] = [];
for (let v = 0; v <= km; v += step) ticks.push(v);
return ticks;
});
function x(d: number): number {
return pad.left + (d / totalDist) * (width - pad.left - pad.right);
}
function y(ele: number): number {
const span = Math.max(eleMax - eleMin, 10);
return pad.top + (1 - (ele - eleMin) / span) * (height - pad.top - pad.bottom);
}
const linePath = $derived(
profile.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.d).toFixed(1)},${y(p.ele).toFixed(1)}`).join('')
);
const areaPath = $derived(
`${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
);
let hover = $state<{ d: number; ele: number } | null>(null);
function onmove(event: PointerEvent) {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
const target = frac * totalDist;
// binary search closest point
let lo = 0;
let hi = profile.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (profile[mid].d < target) lo = mid;
else hi = mid;
}
hover = target - profile[lo].d < profile[hi].d - target ? profile[lo] : profile[hi];
}
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
</script>
<div class="chart-wrap" bind:clientWidth={width}>
<svg viewBox="0 0 {width} {height}" role="img" aria-label="Elevation profile">
{#each yTicks as tick (tick)}
<line x1={pad.left} x2={width - pad.right} y1={y(tick)} y2={y(tick)} class="grid" />
<text x={pad.left - 8} y={y(tick) + 3.5} class="tick" text-anchor="end">
{tick.toLocaleString('en-US')}
</text>
{/each}
{#each xTicks as tick (tick)}
<text x={x(tick * 1000)} y={height - 6} class="tick" text-anchor="middle">
{tick} km
</text>
{/each}
<line
x1={pad.left}
x2={width - pad.right}
y1={height - pad.bottom}
y2={height - pad.bottom}
class="baseline"
/>
<path d={areaPath} class="area" />
<path d={linePath} class="line" />
{#if hover}
<line x1={x(hover.d)} x2={x(hover.d)} y1={pad.top} y2={height - pad.bottom} class="crosshair" />
<circle cx={x(hover.d)} cy={y(hover.ele)} r="5" class="dot" />
{/if}
<rect
role="presentation"
x={pad.left}
y={pad.top}
width={width - pad.left - pad.right}
height={height - pad.top - pad.bottom}
fill="transparent"
onpointermove={onmove}
onpointerleave={() => (hover = null)}
/>
</svg>
{#if hover}
<div class="tooltip" style:left="{tooltipLeft}px">
<span class="tt-value">{Math.round(hover.ele).toLocaleString('en-US')} m</span>
<span class="tt-detail">at {(hover.d / 1000).toFixed(2)} km</span>
</div>
{/if}
</div>
<style>
.chart-wrap {
position: relative;
width: 100%;
}
svg {
display: block;
width: 100%;
}
.grid {
stroke: var(--grid);
stroke-width: 1;
}
.baseline {
stroke: var(--baseline);
stroke-width: 1;
}
.tick {
fill: var(--text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.area {
fill: var(--accent);
opacity: 0.1;
}
.line {
fill: none;
stroke: var(--accent);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.crosshair {
stroke: var(--baseline);
stroke-width: 1;
}
.dot {
fill: var(--accent);
stroke: var(--surface-1);
stroke-width: 2;
}
.tooltip {
position: absolute;
top: 0;
transform: translateX(-50%);
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.3rem 0.6rem;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.tt-value {
font-weight: 600;
font-size: 0.85rem;
}
.tt-detail {
color: var(--text-secondary);
font-size: 0.8rem;
margin-left: 0.35rem;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
import { onMount } from 'svelte';
import 'maplibre-gl/dist/maplibre-gl.css';
export interface MapTrack {
id?: number;
name?: string;
latlngs: [number, number][];
}
export interface MapPeak {
id: number;
name: string;
elevation_m: number;
lat: number;
lon: number;
climbed: boolean;
}
const STYLE_URL = 'https://maptiler.servert.nl/styles/minimal-world-maps/style.json';
const TRACK_COLOR = '#2a78d6';
let {
tracks = [],
peaks = [],
height = '420px',
onpeakclick
}: {
tracks?: MapTrack[];
peaks?: MapPeak[];
height?: string;
onpeakclick?: (id: number) => void;
} = $props();
let container: HTMLDivElement;
let map: import('maplibre-gl').Map | undefined;
let lib: typeof import('maplibre-gl') | undefined;
let markers: import('maplibre-gl').Marker[] = [];
function renderPeaks() {
if (!lib || !map) return;
for (const m of markers) m.remove();
markers = peaks.map((p) => {
const el = document.createElement('div');
el.className = `peak-marker ${p.climbed ? 'climbed' : ''}`;
el.textContent = p.climbed ? '✓' : '▲';
el.title = `${p.name} · ${p.elevation_m.toLocaleString('en-US')} m`;
el.addEventListener('click', (e) => {
e.stopPropagation();
onpeakclick?.(p.id);
});
return new lib!.Marker({ element: el }).setLngLat([p.lon, p.lat]).addTo(map!);
});
}
// re-render markers when climbed state changes
$effect(() => {
void peaks;
renderPeaks();
});
onMount(() => {
let cancelled = false;
(async () => {
const maplibre = await import('maplibre-gl');
if (cancelled) return;
lib = maplibre;
map = new maplibre.Map({
container,
style: STYLE_URL,
center: [9.5, 46.5], // the Alps
zoom: 5,
attributionControl: { compact: true }
});
map.addControl(new maplibre.NavigationControl({ showCompass: false }), 'top-right');
map.on('load', () => {
if (!map) return;
map.addSource('tracks', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: tracks.map((track) => ({
type: 'Feature',
properties: { name: track.name ?? '' },
geometry: {
type: 'LineString',
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
}
}))
}
});
map.addLayer({
id: 'tracks-casing',
type: 'line',
source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#ffffff', 'line-width': 5, 'line-opacity': 0.6 }
});
map.addLayer({
id: 'tracks-line',
type: 'line',
source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': TRACK_COLOR, 'line-width': 2.5 }
});
});
renderPeaks();
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]);
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 40, maxZoom: 14, animate: false });
}
})();
return () => {
cancelled = true;
map?.remove();
};
});
</script>
<div class="map card" bind:this={container} style:height></div>
<style>
.map {
width: 100%;
}
:global(.peak-marker) {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--surface-1);
border: 1.5px solid var(--baseline);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
:global(.peak-marker.climbed) {
background: var(--good);
border-color: var(--good);
color: #fff;
}
:global(.maplibregl-map) {
font: inherit;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
let { label, value, detail = '' }: { label: string; value: string; detail?: string } = $props();
</script>
<div class="tile card">
<div class="label">{label}</div>
<div class="value">{value}</div>
{#if detail}<div class="detail">{detail}</div>{/if}
</div>
<style>
.tile {
padding: 1rem 1.15rem;
}
.label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
}
.value {
font-size: 1.55rem;
font-weight: 600;
letter-spacing: -0.02em;
margin-top: 0.15rem;
}
.detail {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.15rem;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
export function fmtDistance(m: number): string {
if (m < 1000) return `${Math.round(m)} m`;
const km = m / 1000;
return `${km >= 100 ? Math.round(km) : km.toFixed(1)} km`;
}
export function fmtElevation(m: number | null | undefined): string {
if (m === null || m === undefined) return '';
return `${Math.round(m).toLocaleString('en-US')} m`;
}
export function fmtDuration(s: number | null | undefined): string {
if (s === null || s === undefined) return '';
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
if (h === 0) return `${m} min`;
return `${h}:${String(m).padStart(2, '0')} h`;
}
export function fmtDate(iso: string | null | undefined): string {
if (!iso) return 'Unknown date';
return new Date(iso + 'T12:00:00').toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
export function fmtSpeed(mPerS: number): string {
return `${(mPerS * 3.6).toFixed(1)} km/h`;
}
+123
View File
@@ -0,0 +1,123 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { PEAKS } from './peaks-seed';
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 activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 TABLE IF NOT EXISTS peaks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
elevation_m INTEGER NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
country TEXT NOT NULL,
region TEXT NOT NULL,
climbed_at TEXT,
activity_id INTEGER REFERENCES activities(id) ON DELETE SET NULL,
note TEXT
);
`);
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);
})();
export interface ActivityRow {
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;
name: string;
elevation_m: number;
lat: number;
lon: number;
country: string;
region: string;
climbed_at: string | null;
activity_id: number | null;
note: string | null;
}
export function listActivities(): Omit<ActivityRow, 'points'>[] {
return db
.prepare(
`SELECT 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`
)
.all() 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 deleteActivity(id: number): void {
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
}
export function listPeaks(): PeakRow[] {
return db.prepare('SELECT * FROM peaks ORDER BY elevation_m DESC').all() as PeakRow[];
}
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
);
}
return db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow;
}
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);
}
+220
View File
@@ -0,0 +1,220 @@
import { XMLParser } from 'fast-xml-parser';
export interface TrackPoint {
lat: number;
lon: number;
ele: number | null;
/** seconds since the first timestamped point, or null */
t: number | null;
}
export interface ParsedGpx {
name: string | null;
type: string | null;
date: string | null;
points: TrackPoint[];
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;
bounds: [[number, number], [number, number]];
}
const EARTH_RADIUS_M = 6371000;
export function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
const rad = Math.PI / 180;
const dLat = (lat2 - lat1) * rad;
const dLon = (lon2 - lon1) * rad;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a));
}
function asArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
/** Moving-average smoothing for elevation, to keep GPS noise out of gain totals. */
function smooth(values: number[], window = 5): number[] {
const half = Math.floor(window / 2);
return values.map((_, i) => {
const from = Math.max(0, i - half);
const to = Math.min(values.length, i + half + 1);
let sum = 0;
for (let j = from; j < to; j++) sum += values[j];
return sum / (to - from);
});
}
export function parseGpx(xml: string): ParsedGpx {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false
});
const doc = parser.parse(xml);
const gpx = doc.gpx;
if (!gpx) throw new Error('Not a GPX file');
const points: TrackPoint[] = [];
let firstTime: number | null = null;
for (const trk of asArray<any>(gpx.trk)) {
for (const seg of asArray<any>(trk.trkseg)) {
for (const pt of asArray<any>(seg.trkpt)) {
const lat = parseFloat(pt['@_lat']);
const lon = parseFloat(pt['@_lon']);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const ele = pt.ele !== undefined ? parseFloat(pt.ele) : NaN;
let t: number | null = null;
if (pt.time) {
const ms = Date.parse(pt.time);
if (Number.isFinite(ms)) {
if (firstTime === null) firstTime = ms;
t = (ms - firstTime) / 1000;
}
}
points.push({ lat, lon, ele: Number.isFinite(ele) ? ele : null, t });
}
}
}
if (points.length < 2) throw new Error('GPX file contains no usable track');
// distance + moving time
let distance = 0;
let moving = 0;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1];
const b = points[i];
const d = haversine(a.lat, a.lon, b.lat, b.lon);
distance += d;
if (a.t !== null && b.t !== null) {
const dt = b.t - a.t;
if (dt > 0 && d / dt > 0.3) moving += dt;
}
}
// elevation stats on smoothed profile with hysteresis
const eles = points.map((p) => p.ele).filter((e): e is number => e !== null);
let gain = 0;
let loss = 0;
let min: number | null = null;
let max: number | null = null;
if (eles.length > 1) {
min = Math.min(...eles);
max = Math.max(...eles);
const smoothed = smooth(eles);
const threshold = 3; // metres of hysteresis
let ref = smoothed[0];
for (const e of smoothed) {
const diff = e - ref;
if (diff >= threshold) {
gain += diff;
ref = e;
} else if (diff <= -threshold) {
loss += -diff;
ref = e;
}
}
}
const last = points[points.length - 1];
const duration = last.t;
let minLat = Infinity, minLon = Infinity, maxLat = -Infinity, maxLon = -Infinity;
for (const p of points) {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
}
const meta = gpx.metadata;
const date =
firstTime !== null
? new Date(firstTime).toISOString().slice(0, 10)
: meta?.time
? String(meta.time).slice(0, 10)
: null;
const trk0 = asArray<any>(gpx.trk)[0];
return {
name: trk0?.name ? String(trk0.name) : meta?.name ? String(meta.name) : null,
type: trk0?.type ? String(trk0.type).toLowerCase() : null,
date,
points: simplify(points, 2500),
distance_m: distance,
duration_s: duration,
moving_s: duration !== null ? moving : null,
elev_gain_m: gain,
elev_loss_m: loss,
elev_min_m: min,
elev_max_m: max,
bounds: [
[minLat, minLon],
[maxLat, maxLon]
]
};
}
/**
* Downsample a track to at most `target` points, always keeping first/last
* and locally extreme elevation points so profiles stay honest.
*/
function simplify(points: TrackPoint[], target: number): TrackPoint[] {
if (points.length <= target) return points;
const keep = new Set<number>([0, points.length - 1]);
// local elevation extremes
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1].ele;
const cur = points[i].ele;
const next = points[i + 1].ele;
if (prev !== null && cur !== null && next !== null) {
if ((cur > prev && cur > next) || (cur < prev && cur < next)) keep.add(i);
}
}
const step = points.length / target;
for (let i = 0; i < points.length; i += step) keep.add(Math.floor(i));
const indexes = [...keep].sort((a, b) => a - b);
// if extremes pushed us far over target, thin uniformly
if (indexes.length > target * 1.5) {
const thinned: number[] = [];
const s = indexes.length / (target * 1.5);
for (let i = 0; i < indexes.length; i += s) thinned.push(indexes[Math.floor(i)]);
if (thinned[thinned.length - 1] !== points.length - 1) thinned.push(points.length - 1);
return thinned.map((i) => points[i]);
}
return indexes.map((i) => points[i]);
}
/** Peaks whose summit lies within `radius_m` of any track point. */
export function matchPeaks<T extends { lat: number; lon: number }>(
points: TrackPoint[],
peaks: T[],
radius_m = 150
): T[] {
const matched: T[] = [];
for (const peak of peaks) {
// cheap bounding-box prefilter (~1 degree lat = 111 km)
const latMargin = radius_m / 111000;
const lonMargin = radius_m / (111000 * Math.cos((peak.lat * Math.PI) / 180));
for (const p of points) {
if (
Math.abs(p.lat - peak.lat) <= latMargin &&
Math.abs(p.lon - peak.lon) <= lonMargin &&
haversine(p.lat, p.lon, peak.lat, peak.lon) <= radius_m
) {
matched.push(peak);
break;
}
}
}
return matched;
}
+121
View File
@@ -0,0 +1,121 @@
// Curated list of notable Alpine peaks - the Streba peak checklist.
// Coordinates are approximate summit locations (WGS84).
export interface PeakSeed {
name: string;
elevation_m: number;
lat: number;
lon: number;
country: string;
region: string;
}
export const PEAKS: PeakSeed[] = [
// Mont Blanc massif
{ name: 'Mont Blanc', elevation_m: 4808, lat: 45.8326, lon: 6.8652, country: 'FR/IT', region: 'Mont Blanc Massif' },
{ name: 'Mont Maudit', elevation_m: 4465, lat: 45.8477, lon: 6.8757, country: 'FR/IT', region: 'Mont Blanc Massif' },
{ name: 'Mont Blanc du Tacul', elevation_m: 4248, lat: 45.8567, lon: 6.8875, country: 'FR', region: 'Mont Blanc Massif' },
{ name: 'Grandes Jorasses', elevation_m: 4208, lat: 45.8686, lon: 6.986, country: 'FR/IT', region: 'Mont Blanc Massif' },
{ name: 'Aiguille Verte', elevation_m: 4122, lat: 45.9346, lon: 6.97, country: 'FR', region: 'Mont Blanc Massif' },
{ name: 'Aiguille de Bionnassay', elevation_m: 4052, lat: 45.828, lon: 6.818, country: 'FR/IT', region: 'Mont Blanc Massif' },
{ name: 'Dent du Géant', elevation_m: 4013, lat: 45.8622, lon: 6.9518, country: 'FR/IT', region: 'Mont Blanc Massif' },
{ name: 'Les Droites', elevation_m: 4000, lat: 45.9394, lon: 6.9847, country: 'FR', region: 'Mont Blanc Massif' },
{ name: 'Aiguille du Dru', elevation_m: 3754, lat: 45.9325, lon: 6.9573, country: 'FR', region: 'Mont Blanc Massif' },
// Pennine Alps (Valais)
{ name: 'Dufourspitze', elevation_m: 4634, lat: 45.9369, lon: 7.8669, country: 'CH', region: 'Pennine Alps' },
{ name: 'Nordend', elevation_m: 4609, lat: 45.9433, lon: 7.8714, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Zumsteinspitze', elevation_m: 4563, lat: 45.93, lon: 7.872, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Signalkuppe', elevation_m: 4554, lat: 45.9269, lon: 7.8768, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Dom', elevation_m: 4545, lat: 46.0937, lon: 7.8589, country: 'CH', region: 'Pennine Alps' },
{ name: 'Liskamm', elevation_m: 4527, lat: 45.9227, lon: 7.8352, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Weisshorn', elevation_m: 4506, lat: 46.1014, lon: 7.7159, country: 'CH', region: 'Pennine Alps' },
{ name: 'Täschhorn', elevation_m: 4491, lat: 46.0894, lon: 7.8617, country: 'CH', region: 'Pennine Alps' },
{ name: 'Matterhorn', elevation_m: 4478, lat: 45.9766, lon: 7.6585, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Dent Blanche', elevation_m: 4357, lat: 46.0344, lon: 7.6122, country: 'CH', region: 'Pennine Alps' },
{ name: 'Nadelhorn', elevation_m: 4327, lat: 46.1092, lon: 7.8639, country: 'CH', region: 'Pennine Alps' },
{ name: 'Grand Combin', elevation_m: 4314, lat: 45.9375, lon: 7.2986, country: 'CH', region: 'Pennine Alps' },
{ name: 'Lenzspitze', elevation_m: 4294, lat: 46.1042, lon: 7.8672, country: 'CH', region: 'Pennine Alps' },
{ name: 'Castor', elevation_m: 4223, lat: 45.9225, lon: 7.7794, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Zinalrothorn', elevation_m: 4221, lat: 46.0648, lon: 7.6902, country: 'CH', region: 'Pennine Alps' },
{ name: 'Alphubel', elevation_m: 4206, lat: 46.0632, lon: 7.8637, country: 'CH', region: 'Pennine Alps' },
{ name: 'Rimpfischhorn', elevation_m: 4199, lat: 46.0233, lon: 7.8845, country: 'CH', region: 'Pennine Alps' },
{ name: 'Strahlhorn', elevation_m: 4190, lat: 46.0125, lon: 7.901, country: 'CH', region: 'Pennine Alps' },
{ name: "Dent d'Hérens", elevation_m: 4174, lat: 45.9702, lon: 7.6047, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Breithorn', elevation_m: 4164, lat: 45.9419, lon: 7.7484, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Bishorn', elevation_m: 4153, lat: 46.1129, lon: 7.7166, country: 'CH', region: 'Pennine Alps' },
{ name: 'Pollux', elevation_m: 4092, lat: 45.9298, lon: 7.7676, country: 'CH/IT', region: 'Pennine Alps' },
{ name: 'Ober Gabelhorn', elevation_m: 4063, lat: 46.0387, lon: 7.6679, country: 'CH', region: 'Pennine Alps' },
{ name: 'Allalinhorn', elevation_m: 4027, lat: 46.0464, lon: 7.8964, country: 'CH', region: 'Pennine Alps' },
{ name: 'Weissmies', elevation_m: 4017, lat: 46.1276, lon: 8.0128, country: 'CH', region: 'Pennine Alps' },
{ name: 'Lagginhorn', elevation_m: 4010, lat: 46.1571, lon: 8.0033, country: 'CH', region: 'Pennine Alps' },
// Bernese Alps
{ name: 'Finsteraarhorn', elevation_m: 4274, lat: 46.5372, lon: 8.1261, country: 'CH', region: 'Bernese Alps' },
{ name: 'Aletschhorn', elevation_m: 4193, lat: 46.4652, lon: 7.9938, country: 'CH', region: 'Bernese Alps' },
{ name: 'Jungfrau', elevation_m: 4158, lat: 46.5367, lon: 7.9625, country: 'CH', region: 'Bernese Alps' },
{ name: 'Mönch', elevation_m: 4107, lat: 46.5583, lon: 7.9977, country: 'CH', region: 'Bernese Alps' },
{ name: 'Schreckhorn', elevation_m: 4078, lat: 46.5895, lon: 8.1188, country: 'CH', region: 'Bernese Alps' },
{ name: 'Grosses Fiescherhorn', elevation_m: 4049, lat: 46.5519, lon: 8.0561, country: 'CH', region: 'Bernese Alps' },
{ name: 'Eiger', elevation_m: 3967, lat: 46.5775, lon: 8.0053, country: 'CH', region: 'Bernese Alps' },
{ name: 'Wetterhorn', elevation_m: 3690, lat: 46.6053, lon: 8.1175, country: 'CH', region: 'Bernese Alps' },
{ name: 'Blüemlisalphorn', elevation_m: 3660, lat: 46.498, lon: 7.7683, country: 'CH', region: 'Bernese Alps' },
// Dauphiné / Écrins
{ name: 'Barre des Écrins', elevation_m: 4102, lat: 44.9236, lon: 6.3597, country: 'FR', region: 'Écrins' },
{ name: 'La Meije', elevation_m: 3983, lat: 45.0053, lon: 6.3084, country: 'FR', region: 'Écrins' },
{ name: 'Mont Pelvoux', elevation_m: 3946, lat: 44.9089, lon: 6.3689, country: 'FR', region: 'Écrins' },
// Graian Alps / Vanoise
{ name: 'Gran Paradiso', elevation_m: 4061, lat: 45.5163, lon: 7.2668, country: 'IT', region: 'Graian Alps' },
{ name: 'Grande Casse', elevation_m: 3855, lat: 45.4025, lon: 6.8064, country: 'FR', region: 'Vanoise' },
// Bernina & Bregaglia
{ name: 'Piz Bernina', elevation_m: 4049, lat: 46.3823, lon: 9.9083, country: 'CH/IT', region: 'Bernina Range' },
{ name: 'Piz Palü', elevation_m: 3900, lat: 46.3783, lon: 9.9591, country: 'CH/IT', region: 'Bernina Range' },
{ name: 'Piz Badile', elevation_m: 3308, lat: 46.2942, lon: 9.5851, country: 'CH/IT', region: 'Bregaglia' },
// Central & Eastern Switzerland
{ name: 'Dammastock', elevation_m: 3630, lat: 46.6447, lon: 8.4204, country: 'CH', region: 'Urner Alps' },
{ name: 'Tödi', elevation_m: 3614, lat: 46.8113, lon: 8.9151, country: 'CH', region: 'Glarus Alps' },
{ name: 'Titlis', elevation_m: 3238, lat: 46.7716, lon: 8.4376, country: 'CH', region: 'Urner Alps' },
{ name: 'Säntis', elevation_m: 2502, lat: 47.2493, lon: 9.343, country: 'CH', region: 'Appenzell Alps' },
{ name: 'Pilatus', elevation_m: 2128, lat: 46.979, lon: 8.2554, country: 'CH', region: 'Emmental Alps' },
// Ötztal & Stubai Alps
{ name: 'Wildspitze', elevation_m: 3768, lat: 46.8853, lon: 10.8672, country: 'AT', region: 'Ötztal Alps' },
{ name: 'Weißkugel', elevation_m: 3739, lat: 46.7975, lon: 10.7267, country: 'AT/IT', region: 'Ötztal Alps' },
{ name: 'Similaun', elevation_m: 3599, lat: 46.7658, lon: 10.9033, country: 'AT/IT', region: 'Ötztal Alps' },
{ name: 'Zuckerhütl', elevation_m: 3507, lat: 46.9633, lon: 11.1522, country: 'AT', region: 'Stubai Alps' },
// Ortler Alps
{ name: 'Ortler', elevation_m: 3905, lat: 46.5089, lon: 10.5429, country: 'IT', region: 'Ortler Alps' },
{ name: 'Königspitze', elevation_m: 3851, lat: 46.4794, lon: 10.5675, country: 'IT', region: 'Ortler Alps' },
// Zillertal Alps & Hohe Tauern
{ name: 'Hochfeiler', elevation_m: 3509, lat: 46.9722, lon: 11.7267, country: 'AT/IT', region: 'Zillertal Alps' },
{ name: 'Großglockner', elevation_m: 3798, lat: 47.0742, lon: 12.6947, country: 'AT', region: 'Hohe Tauern' },
{ name: 'Großvenediger', elevation_m: 3657, lat: 47.1092, lon: 12.3464, country: 'AT', region: 'Hohe Tauern' },
// Northern Limestone Alps
{ name: 'Zugspitze', elevation_m: 2962, lat: 47.4211, lon: 10.9853, country: 'DE/AT', region: 'Wetterstein' },
{ name: 'Watzmann', elevation_m: 2713, lat: 47.555, lon: 12.92, country: 'DE', region: 'Berchtesgaden Alps' },
{ name: 'Hoher Dachstein', elevation_m: 2995, lat: 47.4753, lon: 13.606, country: 'AT', region: 'Dachstein' },
{ name: 'Hochkönig', elevation_m: 2941, lat: 47.4203, lon: 13.0631, country: 'AT', region: 'Berchtesgaden Alps' },
{ name: 'Birkkarspitze', elevation_m: 2749, lat: 47.4116, lon: 11.437, country: 'AT', region: 'Karwendel' },
{ name: 'Parseierspitze', elevation_m: 3036, lat: 47.1758, lon: 10.4712, country: 'AT', region: 'Lechtal Alps' },
// Rätikon & Silvretta
{ name: 'Schesaplana', elevation_m: 2964, lat: 47.0533, lon: 9.7078, country: 'AT/CH', region: 'Rätikon' },
{ name: 'Piz Buin', elevation_m: 3312, lat: 46.8442, lon: 10.1188, country: 'AT/CH', region: 'Silvretta' },
// Dolomites
{ name: 'Marmolada', elevation_m: 3343, lat: 46.4343, lon: 11.8514, country: 'IT', region: 'Dolomites' },
{ name: 'Antelao', elevation_m: 3264, lat: 46.4517, lon: 12.262, country: 'IT', region: 'Dolomites' },
{ name: 'Langkofel', elevation_m: 3181, lat: 46.5175, lon: 11.7061, country: 'IT', region: 'Dolomites' },
{ name: 'Monte Pelmo', elevation_m: 3168, lat: 46.4172, lon: 12.1219, country: 'IT', region: 'Dolomites' },
{ name: 'Cima Grande di Lavaredo', elevation_m: 2999, lat: 46.6188, lon: 12.3025, country: 'IT', region: 'Dolomites' },
// Julian Alps
{ name: 'Triglav', elevation_m: 2864, lat: 46.3783, lon: 13.8367, country: 'SI', region: 'Julian Alps' }
];
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import '@fontsource-variable/inter';
import '../app.css';
import { page } from '$app/state';
let { children } = $props();
const links = [
{ href: '/', label: 'Dashboard' },
{ href: '/activities', label: 'Activities' },
{ href: '/peaks', label: 'Peaks' },
{ href: '/upload', label: 'Upload' }
];
function isActive(href: string): boolean {
if (href === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(href);
}
</script>
<svelte:head>
<title>Streba</title>
</svelte:head>
<header class="topbar">
<nav class="topbar-inner">
<a href="/" class="wordmark">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M2 20 L9 6 L13 13 L16 9 L22 20 Z" fill="currentColor" />
</svg>
Streba
</a>
<div class="nav-links">
{#each links as link (link.href)}
<a href={link.href} class:active={isActive(link.href)}>{link.label}</a>
{/each}
</div>
</nav>
</header>
<main>
{@render children()}
</main>
<footer class="footer">Streba - the GPX analyser</footer>
<style>
.topbar {
position: sticky;
top: 0;
z-index: 1100;
background: color-mix(in srgb, var(--surface-1) 82%, transparent);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.topbar-inner {
max-width: 1080px;
margin: 0 auto;
padding: 0 1.25rem;
height: 3.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.wordmark {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.02em;
color: var(--text-primary);
text-decoration: none;
}
.wordmark svg {
color: var(--accent);
}
.nav-links {
display: flex;
gap: 0.25rem;
}
.nav-links a {
padding: 0.4rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
transition: background 120ms, color 120ms;
}
.nav-links a:hover {
background: var(--wash);
color: var(--text-primary);
}
.nav-links a.active {
background: var(--accent-wash);
color: var(--accent-strong);
}
main {
max-width: 1080px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
}
.footer {
max-width: 1080px;
margin: 0 auto;
padding: 1.5rem 1.25rem 2.5rem;
color: var(--text-muted);
font-size: 0.8rem;
border-top: 1px solid var(--border);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
import { db, listActivities, listPeaks } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
const activities = listActivities();
const peaks = listPeaks();
// 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;
}[];
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));
const latlngs = points
.filter((_, i) => i % step === 0 || i === points.length - 1)
.map((p) => [p.lat, p.lon] as [number, number]);
return { id: row.id, name: row.name, latlngs };
});
return {
activities,
tracks,
totals: {
count: activities.length,
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
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
};
};
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import Map from '$lib/components/Map.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import ActivityItem from '$lib/components/ActivityItem.svelte';
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
let { data } = $props();
</script>
<svelte:head>
<title>Dashboard · Streba</title>
</svelte:head>
<h1>Dashboard</h1>
<p class="page-sub">Every track you've recorded, in one place.</p>
{#if data.totals.count === 0}
<div class="card empty">
<p><strong>Welcome to Streba.</strong></p>
<p>Upload your first GPX track to get going, or start ticking off Alpine peaks right away.</p>
<div class="empty-actions">
<a class="btn" href="/upload">Upload a GPX file</a>
<a class="btn ghost" href="/peaks">Browse the peaks</a>
</div>
</div>
{:else}
<div class="kpis">
<StatTile label="Activities" value={String(data.totals.count)} />
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
</div>
<h2>Worldmap</h2>
<Map tracks={data.tracks} height="440px" />
<div class="lower">
<section>
<h2>Recent activities</h2>
<div class="card list">
{#each data.activities.slice(0, 6) as activity (activity.id)}
<ActivityItem {activity} />
{/each}
</div>
</section>
<section>
<h2>Peak bagging</h2>
<div class="card peak-progress">
<div class="meter-nums">
<span class="big">{data.peaksClimbed}</span>
<span class="of">of {data.peaksTotal} peaks climbed</span>
</div>
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={data.peaksTotal} aria-valuenow={data.peaksClimbed} aria-label="Peaks climbed">
<div class="fill" style:width="{(data.peaksClimbed / data.peaksTotal) * 100}%"></div>
</div>
<a class="btn ghost" href="/peaks">Open the checklist</a>
</div>
</section>
</div>
{/if}
<style>
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.empty {
padding: 2.5rem;
text-align: center;
}
.empty p {
margin: 0.25rem 0;
color: var(--text-secondary);
}
.empty p strong {
color: var(--text-primary);
font-size: 1.1rem;
}
.empty-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1.25rem;
}
.lower {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 720px) {
.lower {
grid-template-columns: 1fr;
}
}
.list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border);
}
.peak-progress {
padding: 1.15rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
align-items: flex-start;
}
.meter-nums .big {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.meter-nums .of {
color: var(--text-secondary);
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;
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import { listActivities } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
return { activities: listActivities() };
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import ActivityItem from '$lib/components/ActivityItem.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Activities · Streba</title>
</svelte:head>
<h1>Activities</h1>
<p class="page-sub">
{data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}.
</p>
{#if data.activities.length === 0}
<div class="card empty">
<p>No activities yet.</p>
<a class="btn" href="/upload">Upload a GPX file</a>
</div>
{:else}
<div class="card list">
{#each data.activities as activity (activity.id)}
<ActivityItem {activity} />
{/each}
</div>
{/if}
<style>
.list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border);
}
.empty {
padding: 2.5rem;
text-align: center;
}
.empty p {
color: var(--text-secondary);
margin: 0 0 1rem;
}
</style>
@@ -0,0 +1,54 @@
import { error, redirect } from '@sveltejs/kit';
import { db, 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));
if (!activity) error(404, 'Activity not found');
const points = JSON.parse(activity.points) as {
lat: number;
lon: number;
ele: number | null;
t: number | null;
}[];
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
// cumulative-distance elevation profile
const profile: { d: number; ele: number }[] = [];
let dist = 0;
for (let i = 0; i < points.length; i++) {
if (i > 0) {
dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
}
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[];
return {
activity: { ...activity, points: undefined },
latlngs,
profile,
bagged: bagged.map((p) => ({
id: p.id,
name: p.name,
elevation_m: p.elevation_m,
lat: p.lat,
lon: p.lon,
climbed: true
}))
};
};
export const actions: Actions = {
delete: async ({ params }) => {
deleteActivity(Number(params.id));
redirect(303, '/activities');
}
};
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Map from '$lib/components/Map.svelte';
import ElevationChart from '$lib/components/ElevationChart.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
let { data } = $props();
const a = $derived(data.activity);
const avgSpeed = $derived(
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
);
</script>
<svelte:head>
<title>{a.name} · Streba</title>
</svelte:head>
<div class="head">
<div>
<h1>{a.name}</h1>
<p class="page-sub">{fmtDate(a.date)} · <span class="type">{a.type}</span></p>
</div>
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel();
}}
>
<button class="btn danger" type="submit">Delete</button>
</form>
</div>
<div class="kpis">
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
<StatTile label="Ascent" value={fmtElevation(a.elev_gain_m)} detail="↓ {fmtElevation(a.elev_loss_m)}" />
<StatTile
label="Moving time"
value={fmtDuration(a.moving_s ?? a.duration_s)}
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ''}
/>
{#if avgSpeed}
<StatTile label="Avg moving speed" value={avgSpeed} />
{:else}
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
{/if}
</div>
{#if data.bagged.length > 0}
<div class="card bagged">
⛰ This track bagged
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ', ' : ' '}<strong>{peak.name}</strong> ({peak.elevation_m} m){/each}
</div>
{/if}
<h2>Map</h2>
<Map tracks={[{ id: a.id, name: a.name, latlngs: data.latlngs }]} peaks={data.bagged} height="440px" />
{#if data.profile.length > 1}
<h2>Elevation profile</h2>
<div class="card chart">
<ElevationChart profile={data.profile} />
<div class="chart-meta">
<span>Low {fmtElevation(a.elev_min_m)}</span>
<span>High {fmtElevation(a.elev_max_m)}</span>
</div>
</div>
{/if}
<style>
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.type {
text-transform: capitalize;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.75rem;
}
.bagged {
margin-top: 1rem;
padding: 0.85rem 1.15rem;
border-left: 3px solid var(--good);
color: var(--text-secondary);
}
.bagged strong {
color: var(--text-primary);
}
.chart {
padding: 1rem 1rem 0.5rem;
}
.chart-meta {
display: flex;
gap: 1.25rem;
padding: 0.5rem 0.25rem 0.6rem;
font-size: 0.8rem;
color: var(--text-muted);
}
</style>
@@ -0,0 +1,9 @@
import { error, json } from '@sveltejs/kit';
import { togglePeak } 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);
};
+6
View File
@@ -0,0 +1,6 @@
import { listPeaks } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
return { peaks: listPeaks() };
};
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts">
import Map from '$lib/components/Map.svelte';
import { fmtDate } from '$lib/format';
let { data } = $props();
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
let peaks = $state(data.peaks);
$effect(() => {
peaks = data.peaks;
});
let filter: 'all' | 'climbed' | 'remaining' = $state('all');
const climbed = $derived(peaks.filter((p) => p.climbed_at).length);
const highest = $derived(peaks.filter((p) => p.climbed_at).sort((a, b) => b.elevation_m - a.elevation_m)[0]);
const mapPeaks = $derived(
peaks.map((p) => ({
id: p.id,
name: p.name,
elevation_m: p.elevation_m,
lat: p.lat,
lon: p.lon,
climbed: !!p.climbed_at
}))
);
const regions = $derived.by(() => {
const visible = peaks.filter((p) =>
filter === 'all' ? true : filter === 'climbed' ? !!p.climbed_at : !p.climbed_at
);
const byRegion = new globalThis.Map<string, typeof visible>();
for (const p of visible) {
if (!byRegion.has(p.region)) byRegion.set(p.region, []);
byRegion.get(p.region)!.push(p);
}
return [...byRegion.entries()].sort(
(a, b) => Math.max(...b[1].map((p) => p.elevation_m)) - Math.max(...a[1].map((p) => p.elevation_m))
);
});
async function toggle(id: number) {
const res = await fetch(`/api/peaks/${id}/toggle`, { method: 'POST' });
if (!res.ok) return;
const updated = await res.json();
peaks = peaks.map((p) => (p.id === id ? updated : p));
}
</script>
<svelte:head>
<title>Peaks · Streba</title>
</svelte:head>
<h1>Alpine peaks</h1>
<p class="page-sub">
{climbed} of {peaks.length} peaks climbed{#if highest}&nbsp;· 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.
</p>
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={peaks.length} aria-valuenow={climbed} aria-label="Peaks climbed">
<div class="fill" style:width="{(climbed / peaks.length) * 100}%"></div>
</div>
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} />
<div class="filters" role="group" aria-label="Filter peaks">
{#each [['all', 'All'], ['remaining', 'To climb'], ['climbed', 'Climbed']] as [key, label] (key)}
<button
class="filter-btn"
class:on={filter === key}
onclick={() => (filter = key as typeof filter)}
>
{label}
</button>
{/each}
</div>
{#each regions as [region, list] (region)}
<h2>{region}</h2>
<div class="card grid">
{#each list as peak (peak.id)}
<button class="peak" class:done={peak.climbed_at} onclick={() => toggle(peak.id)}>
<span class="check" aria-hidden="true">{peak.climbed_at ? '✓' : ''}</span>
<span class="info">
<span class="name">{peak.name}</span>
<span class="sub">
{peak.elevation_m.toLocaleString('en-US')} m · {peak.country}
{#if peak.climbed_at}
· climbed {fmtDate(peak.climbed_at)}
{/if}
</span>
</span>
</button>
{/each}
</div>
{:else}
<div class="card empty-filter">Nothing here - try another filter.</div>
{/each}
<style>
.meter {
width: 100%;
height: 8px;
border-radius: 999px;
background: var(--accent-track);
margin-bottom: 1.25rem;
}
.fill {
height: 100%;
border-radius: 999px;
background: var(--accent);
min-width: 2px;
}
.filters {
display: flex;
gap: 0.4rem;
margin-top: 1.5rem;
}
.filter-btn {
padding: 0.35rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
}
.filter-btn:hover {
background: var(--wash);
}
.filter-btn.on {
background: var(--accent-wash);
border-color: transparent;
color: var(--accent-strong);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.peak {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.8rem 1rem;
border: none;
background: none;
font: inherit;
text-align: left;
color: inherit;
cursor: pointer;
transition: background 120ms;
}
.peak:hover {
background: var(--wash);
}
.check {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 6px;
border: 1.5px solid var(--baseline);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: #fff;
transition: background 120ms, border-color 120ms;
}
.peak.done .check {
background: var(--good);
border-color: var(--good);
}
.info {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
font-weight: 600;
font-size: 0.92rem;
}
.peak.done .name {
text-decoration: line-through;
text-decoration-thickness: 1.5px;
text-decoration-color: var(--text-muted);
color: var(--text-secondary);
}
.sub {
font-size: 0.78rem;
color: var(--text-muted);
}
.empty-filter {
margin-top: 1rem;
padding: 1.5rem;
text-align: center;
color: var(--text-secondary);
}
</style>
+60
View File
@@ -0,0 +1,60 @@
import { fail } from '@sveltejs/kit';
import { db, listPeaks, markPeakClimbed } from '$lib/server/db';
import { matchPeaks, parseGpx } from '$lib/server/gpx';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
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.' });
const uploaded: { id: number; name: string; newPeaks: string[] }[] = [];
const errors: string[] = [];
const insert = db.prepare(`
INSERT INTO activities
(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,
@elev_gain_m, @elev_loss_m, @elev_min_m, @elev_max_m, @points, @bounds)
`);
for (const file of files) {
try {
const gpx = parseGpx(await file.text());
const result = insert.run({
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''),
type: gpx.type ?? 'outdoor',
date: gpx.date,
distance_m: gpx.distance_m,
duration_s: gpx.duration_s,
moving_s: gpx.moving_s,
elev_gain_m: gpx.elev_gain_m,
elev_loss_m: gpx.elev_loss_m,
elev_min_m: gpx.elev_min_m,
elev_max_m: gpx.elev_max_m,
points: JSON.stringify(gpx.points),
bounds: JSON.stringify(gpx.bounds)
});
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);
uploaded.push({
id: activityId,
name: gpx.name ?? file.name,
newPeaks: bagged.map((p) => p.name)
});
} catch (err) {
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
}
}
if (uploaded.length === 0) return fail(422, { error: errors.join(' ') });
return { uploaded, errors };
}
};
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
let dragging = $state(false);
let fileInput: HTMLInputElement;
let formEl: HTMLFormElement;
let selectedNames: string[] = $state([]);
let submitting = $state(false);
function onDrop(event: DragEvent) {
event.preventDefault();
dragging = false;
if (!event.dataTransfer) return;
fileInput.files = event.dataTransfer.files;
updateNames();
}
function updateNames() {
selectedNames = [...(fileInput.files ?? [])].map((f) => f.name);
}
</script>
<svelte:head>
<title>Upload · Streba</title>
</svelte:head>
<h1>Upload</h1>
<p class="page-sub">Drop GPX files here - peaks along the track are ticked off automatically.</p>
<form
method="POST"
enctype="multipart/form-data"
bind:this={formEl}
use:enhance={() => {
submitting = true;
return async ({ update }) => {
submitting = false;
selectedNames = [];
await update();
};
}}
>
<button
type="button"
class="dropzone card"
class:dragging
onclick={() => fileInput.click()}
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
>
<svg viewBox="0 0 24 24" width="36" height="36" aria-hidden="true">
<path d="M2 20 L9 6 L13 13 L16 9 L22 20 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
<path d="M12 2 v0" stroke="currentColor" />
</svg>
{#if selectedNames.length > 0}
<strong>{selectedNames.length} file{selectedNames.length > 1 ? 's' : ''} selected</strong>
<span class="hint">{selectedNames.join(', ')}</span>
{:else}
<strong>Drop GPX files here</strong>
<span class="hint">or click to browse</span>
{/if}
</button>
<input
type="file"
name="gpx"
accept=".gpx,application/gpx+xml"
multiple
hidden
bind:this={fileInput}
onchange={updateNames}
/>
<button class="btn submit" type="submit" disabled={submitting || selectedNames.length === 0}>
{submitting ? 'Analysing…' : 'Analyse'}
</button>
</form>
{#if form?.error}
<div class="card notice error">{form.error}</div>
{/if}
{#if form?.uploaded}
<div class="card notice">
{#each form.uploaded as item (item.id)}
<p>
<a href="/activities/{item.id}"><strong>{item.name}</strong></a> analysed.
{#if item.newPeaks.length > 0}
<span class="bagged">⛰ New peak{item.newPeaks.length > 1 ? 's' : ''} bagged: {item.newPeaks.join(', ')}!</span>
{/if}
</p>
{/each}
{#each form.errors as message (message)}
<p class="err-line">{message}</p>
{/each}
</div>
{/if}
<style>
form {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.dropzone {
width: 100%;
padding: 3rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
border: 1.5px dashed var(--baseline);
background: var(--surface-1);
color: var(--text-secondary);
font: inherit;
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
.dropzone:hover,
.dropzone.dragging {
border-color: var(--accent);
background: var(--accent-wash);
}
.dropzone svg {
color: var(--accent);
margin-bottom: 0.5rem;
}
.dropzone strong {
color: var(--text-primary);
}
.hint {
font-size: 0.85rem;
color: var(--text-muted);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.submit:disabled {
opacity: 0.5;
cursor: default;
}
.notice {
margin-top: 1.25rem;
padding: 1rem 1.25rem;
}
.notice p {
margin: 0.25rem 0;
}
.notice a {
color: var(--accent-strong);
text-decoration: none;
}
.bagged {
color: var(--good-text);
font-weight: 500;
}
.error,
.err-line {
color: var(--critical);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#1c5cab" />
<path d="M3 19 L9.5 7 L13 13 L15.5 9.5 L21 19 Z" fill="#fcfcfb" />
</svg>

After

Width:  |  Height:  |  Size: 191 B

+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});