stammtisch-hersbruck/docs/superpowers/specs/2026-08-06-trails-design.md
Daniel Michelberger f11f7b4dff docs: Spec für Trails mit GPX, Markern und Kommentaren
Trail-Katalog pro Team: GPX hochladen, auf Karte anzeigen, verortete
Meldungen setzen, kommentieren. Neue Uploads legen Versionen an statt zu
überschreiben; Marker und Kommentare hängen am Trail und überleben damit
einen Track-Austausch.

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

306 lines
12 KiB
Markdown

# 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 `<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`)