diff --git a/docs/superpowers/specs/2026-08-06-trails-design.md b/docs/superpowers/specs/2026-08-06-trails-design.md new file mode 100644 index 0000000..59670aa --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-trails-design.md @@ -0,0 +1,306 @@ +# 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). + +### `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 `` mit optionalem `` 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 ``-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 `` statt +``. 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`)