stammtisch-hersbruck/docs/superpowers/specs/2026-08-06-trails-design.md
Daniel Michelberger 564632522e docs: Begründung für trails.current statt active-Flag ergänzen
Die Alternative (bool active auf trail_versions) käme ohne Zirkelbezug aus,
verlagert die Wahrheit aber in N Datensätze: zwei Schreibvorgänge beim
Umschalten, ohne Transaktion kurzzeitig zwei aktive Versionen möglich.
trails.current ändert genau ein Feld und kennt keinen ungültigen Zustand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:33:58 +02:00

12 KiB

Trails: GPX-Tracks, Marker, Flags und Kommentare

Datum: 2026-08-06 Status: Entwurf, vom Nutzer freigegeben

Ziel

Teams sollen GPX-Tracks hochladen, beschriften und auf einer Karte ansehen können. Zu jedem Trail lassen sich verortete Meldungen setzen (Baum quer, Erosion, gesperrt) und Kommentare schreiben. Neue GPX-Uploads ersetzen den Track nicht, sondern legen eine Version an — Korrekturen bleiben nachvollziehbar.

Ausgangslage

  • SvelteKit 5 mit Svelte-5-Runes, PocketBase als Backend (Monorepo: frontend/, backend/)
  • Bestehende Collections: users, teams, events, runs, riders, times
  • Alles ist teamgebunden: Die API-Rules lauten durchweg team.users.id ?= @request.auth.id
  • Store-Muster: Klasse mit $state, Context über Symbol, Realtime-Subscription, scoped-Getter für das aktive Team (siehe src/lib/stores/events.svelte.ts)
  • Keine Kartenbibliothek vorhanden — MapLibre GL kommt neu dazu
  • Das Schema ist als Migration in backend/pb_migrations/ versioniert; Typen entstehen daraus über npm run generate-pocketbase-types

Entscheidungen

Frage Entscheidung
Zweck Eigenständiger Trail-Katalog, optional einem Event zugeordnet
Sichtbarkeit Teamspezifisch, keine öffentlichen Trails
Bearbeiten Trail-Paten (stewards) und Team-Admins
Flags setzen Jedes Team-Mitglied — schnelle Information hat Vorrang
Flag-Typen Eigene Collection, im UI pflegbar
Flag-Bezug Trail-Gesamtstatus und verortete Marker
GPX-Speicherung Originaldatei und extrahierte Geometrie
Parsing Im Browser beim Upload — kein pb_hooks nötig
Versionierung Trail = Identität, Versionen als eigene Collection
Marker/Kommentare Hängen am Trail, nicht an der Version
Karte MapLibre GL mit OSM-Raster
Höhenprofil Ja, verknüpft mit der Karte

Datenmodell

Fünf neue Collections. Alle tragen team und die Standardfelder created und updated als autodate.

trails

Die dauerhafte Identität eines Trails.

Feld Typ Anmerkung
team relation → teams Pflicht, single
name text Pflicht
description editor Rich-Text
status select offen, gesperrt, eingeschraenkt
stewards relation → users mehrfach, die Trail-Paten
event relation → events optional, single
current relation → trail_versions die aktive Version, single
created_by relation → users single

current erzeugt einen Zirkelbezug zu trail_versions.trail. PocketBase verträgt das, die Migration muss die Collections aber in zwei Schritten anlegen (siehe „Migration" unten).

Warum nicht ein active-Flag auf trail_versions? Das wäre ohne Zirkelbezug ausgekommen, verlagert die Wahrheit aber in N Datensätze statt einen: Beim Umschalten müssten zwei Versionen geschrieben werden, und ohne Transaktion kann es kurzzeitig zwei aktive oder gar keine geben. Mit trails.current ändert sich genau ein Feld, und ein ungültiger Zustand ist nicht möglich. Der Zirkelbezug kostet dafür einen zusätzlichen Migrationsschritt — einmalig, gegenüber einer Fehlerquelle im Betrieb.

Dass current theoretisch auf eine Version eines anderen Trails zeigen könnte, fängt das Frontend ab: activate(versionId) prüft vorher, dass die Version zum Trail gehört.

trail_versions

Jeder GPX-Upload erzeugt einen Datensatz. Sie werden nie überschrieben.

Feld Typ Anmerkung
trail relation → trails Pflicht, single
gpx file das Original, herunterladbar, max. 10 MB
geojson json LineString für die Karte, vereinfacht
distance_m number Streckenlänge in Metern
ascent_m number Höhenmeter aufwärts
elevation json Array [{d, ele}] für das Höhenprofil
bounds json [[minLng, minLat], [maxLng, maxLat]]
note text was an dieser Version anders ist
uploaded_by relation → users single

trail_flags

Die Flag-Typen, pro Team pflegbar.

Feld Typ Anmerkung
team relation → teams Pflicht
label text Pflicht, z. B. „Baum quer"
icon text Name eines Lucide-Icons
color text Hex-Farbe, z. B. #dc2626
severity select info, warnung, kritisch

Beim Anlegen eines Teams gibt es keine automatischen Standard-Flags; die Flag-Verwaltung bietet stattdessen einen Knopf „Standardsatz anlegen", der sechs übliche Typen erzeugt (Baum quer, Erosion, Verblockt, Sperrung, Bauarbeiten, Hinweis). Das hält die Migration frei von Seed-Daten.

trail_markers

Verortete Meldungen. Sie hängen am Trail, nicht an einer Version — ein umgestürzter Baum liegt im Gelände, nicht in einer Datei.

Feld Typ Anmerkung
trail relation → trails Pflicht
team relation → teams Pflicht, für die API-Rules
flag relation → trail_flags Pflicht
lat number Pflicht
lng number Pflicht
note text freie Beschreibung
resolved bool erledigt statt gelöscht
created_by relation → users single

trail_comments

Feld Typ Anmerkung
trail relation → trails Pflicht
team relation → teams Pflicht, für die API-Rules
text text Pflicht
created_by relation → users single

team wird auf markers und comments mitgeführt, obwohl es über trail.team erreichbar wäre. Grund: Die API-Rule bleibt damit eine direkte Prüfung statt einer Relation über zwei Ebenen — das ist schneller und robuster gegen Tippfehler in der Regel.

API-Rules

trails

listRule   team.users.id ?= @request.auth.id
viewRule   team.users.id ?= @request.auth.id
createRule team.users.id ?= @request.auth.id
updateRule stewards.id ?= @request.auth.id
           || team.owner.id ?= @request.auth.id
           || team.admins.id ?= @request.auth.id
deleteRule team.owner.id ?= @request.auth.id
           || team.admins.id ?= @request.auth.id

Anlegen darf jedes Team-Mitglied; wer anlegt, wird im Frontend automatisch als erster Pate eingetragen. Löschen ist Admins vorbehalten — ein Trail trägt Kommentare und Marker anderer Leute.

trail_versions

listRule/viewRule    trail.team.users.id ?= @request.auth.id
createRule           trail.stewards.id ?= @request.auth.id
                     || trail.team.owner.id ?= @request.auth.id
                     || trail.team.admins.id ?= @request.auth.id
updateRule           null   (Versionen sind unveränderlich)
deleteRule           trail.team.owner.id ?= @request.auth.id
                     || trail.team.admins.id ?= @request.auth.id

trail_flags

listRule/viewRule                  team.users.id ?= @request.auth.id
createRule/updateRule/deleteRule   team.owner.id ?= @request.auth.id
                                   || team.admins.id ?= @request.auth.id

trail_markers und trail_comments

listRule/viewRule   team.users.id ?= @request.auth.id
createRule          team.users.id ?= @request.auth.id
updateRule          created_by.id ?= @request.auth.id
                    || trail.stewards.id ?= @request.auth.id
                    || team.owner.id ?= @request.auth.id
                    || team.admins.id ?= @request.auth.id
deleteRule          (wie updateRule)

GPX-Verarbeitung

Das Parsen läuft im Browser (src/lib/gpx.ts), das Backend bleibt reines PocketBase ohne Hooks.

Ablauf beim Upload:

  1. Datei über DOMParser als XML lesen
  2. Alle <trkpt lat lng> mit optionalem <ele> einsammeln
  3. Streckenlänge über die Haversine-Formel aufsummieren
  4. Höhenmeter aufwärts summieren, dabei Schwankungen unter 3 m ignorieren (GPS-Rauschen würde sonst absurde Werte erzeugen)
  5. Bounding-Box bestimmen
  6. Punktfolge für die Anzeige vereinfachen (Douglas-Peucker, Toleranz so gewählt, dass höchstens ~2000 Punkte übrig bleiben)
  7. Original-Datei und abgeleitete Werte gemeinsam an PocketBase schicken

Fehlerbehandlung: Enthält die Datei keine <trkpt>-Elemente oder ist sie kein gültiges XML, wird der Upload mit einer verständlichen Meldung abgelehnt. Dateien über 10 MB werden vorab abgewiesen.

Routen statt Tracks: Manche GPX-Dateien enthalten <rtept> statt <trkpt>. Fehlen Trackpunkte, greift der Parser auf Routenpunkte zurück, bevor er aufgibt.

Karte

MapLibre GL JS mit OSM-Raster-Kacheln (tile.openstreetmap.org), kein API-Schlüssel nötig. Die Kachel-URL steht in einer Konstanten, damit sie später gegen eine Outdoor-Karte getauscht werden kann.

Die Karte lebt in src/lib/components/TrailMap.svelte mit den Props: geojson, markers, status, interactive. Sie kennt weder Stores noch PocketBase — Daten kommen ausschließlich über Props, Ereignisse gehen über Callback-Props zurück. Damit ist sie in Liste, Detail und Marker-Setzmodus gleichermaßen verwendbar.

Die Trail-Linie wird nach status eingefärbt: offen grün, eingeschränkt gelb, gesperrt rot. Marker tragen Icon und Farbe ihres Flag-Typs; erledigte Marker (resolved) erscheinen blass und lassen sich ausblenden.

Marker setzen: Ein Umschalter versetzt die Karte in den Setzmodus; der nächste Klick öffnet einen Dialog mit Flag-Auswahl und Notiz.

Höhenprofil

src/lib/components/ElevationProfile.svelte zeichnet die Werte aus trail_versions.elevation als SVG-Fläche — keine Diagramm-Bibliothek, die Daten sind eine einfache Zahlenreihe.

Beim Überfahren wird die entsprechende Stelle auf der Karte hervorgehoben. Die Verknüpfung läuft über einen Index in der Punktfolge, den beide Komponenten teilen; die Elternkomponente hält ihn als $state.

Stores

Vier neue Stores nach dem Muster von events.svelte.ts — Klasse mit $state, Context über Symbol, scoped-Getter, Realtime-Subscription:

  • trails.svelte.ts — Trails samt create, edit, remove
  • trailVersions.svelte.ts — Versionen eines Trails, upload, activate
  • trailFlags.svelte.ts — Flag-Typen, seedDefaults()
  • trailMarkers.svelte.ts — Marker, toggleResolved

Kommentare bekommen keinen eigenen Store, sondern werden in der Detailseite direkt geladen — sie werden nur an einer Stelle gebraucht.

Routen

Route Inhalt
/dashboard/trails Liste mit Übersichtskarte aller Trails des Teams
/dashboard/trails/[id] Karte, Höhenprofil, Marker, Kommentare, Versionen
/dashboard/settings/flags Flag-Typen verwalten

Die Trail-Detailseite gliedert sich in Karte (mit Höhenprofil darunter) und einen Bereich mit Reitern für Marker, Kommentare und Versionen.

Migration

Eine neue Datei in backend/pb_migrations/. Zwei Besonderheiten:

  1. Zirkelbezug: trails.current zeigt auf trail_versions, das seinerseits auf trails zeigt. Die Migration legt deshalb zuerst trails ohne das Feld current an, dann trail_versions, und ergänzt current in einem zweiten Schritt.
  2. Keine Seed-Daten. Standard-Flags entstehen auf Knopfdruck im UI, nicht in der Migration — sonst kämen sie bei jedem Containerstart zurück.

Nach der Migration ist npm run generate-pocketbase-types auszuführen und frontend/src/lib/types.d.ts mitzucommitten.

Neue Abhängigkeiten

Paket Zweck
maplibre-gl Kartendarstellung

Mehr nicht: GPX-Parsing, Douglas-Peucker und das Höhenprofil sind wenige Dutzend Zeilen und kommen ohne Bibliothek aus.

Verifikation

  1. Migration im lokalen Container: alle fünf Collections vorhanden, Felder und Rules wie beschrieben
  2. Eine echte GPX-Datei hochladen: Länge und Höhenmeter plausibel gegenüber dem Wert, den ein anderes Werkzeug (z. B. gpx.studio) nennt
  3. Track erscheint auf der Karte, Kartenausschnitt passt zur Bounding-Box
  4. Höhenprofil zeigt einen Verlauf, Hover markiert die Stelle auf der Karte
  5. Marker setzen, Flag zuweisen, als erledigt markieren
  6. Kommentar schreiben, erscheint bei einem zweiten Browser per Realtime
  7. Zweite GPX-Version hochladen: Version wird angelegt, Marker und Kommentare bleiben erhalten
  8. Rechte: Ein Team-Mitglied ohne Patenschaft kann Marker und Kommentare anlegen, aber den Trail nicht umbenennen
  9. npm run check ohne neue Fehler

Bewusst nicht Teil dieser Umsetzung

  • Öffentliche, nicht angemeldete Trail-Ansicht
  • Bestätigungs-Workflow für Meldungen (jeder im Team meldet direkt)
  • Automatisches Aufräumen alter Versionen
  • Ein Trail in mehreren Events gleichzeitig
  • Offline-Karten, Track-Aufzeichnung im Browser
  • Verknüpfung der Trails mit der Zeitnahme (runs, times)