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>
This commit is contained in:
parent
f316058367
commit
f11f7b4dff
1 changed files with 306 additions and 0 deletions
306
docs/superpowers/specs/2026-08-06-trails-design.md
Normal file
306
docs/superpowers/specs/2026-08-06-trails-design.md
Normal file
|
|
@ -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 `<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`)
|
||||
Loading…
Reference in a new issue