stammtisch-hersbruck/docs/superpowers/plans/2026-08-06-trails.md
Daniel Michelberger 1257456308 docs: Plan-Korrekturen aus der Umsetzung nachziehen
- {@html} der Trail-Beschreibung mit DOMPurify absichern
- fehlender Key in der Status-Schleife

Beides fiel beim Autofixer-Lauf in Task 8 auf und stand so im Plan.

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

3189 lines
111 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Trails: GPX, Marker, Flags und Kommentare — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Teams können GPX-Tracks hochladen, auf einer Karte ansehen, verortete Meldungen setzen und kommentieren; neue Uploads legen Versionen an statt zu überschreiben.
**Architecture:** Fünf neue PocketBase-Collections, versioniert als Migration in `backend/pb_migrations/`. Das GPX wird im Browser geparst (kein `pb_hooks`), Originaldatei und abgeleitete Geometrie werden gemeinsam gespeichert. Die Karte ist eine dumme Präsentationskomponente ohne Store-Zugriff; Daten fließen ausschließlich über Props.
**Tech Stack:** SvelteKit 5 / Svelte 5 Runes, TypeScript, PocketBase 0.39.6, MapLibre GL, Tailwind 4, shadcn-svelte.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-08-06-trails-design.md`
- **Svelte 5 Runes ausschließlich** — `$state`, `$derived`, `$props`, `$effect`. Keine Legacy-Syntax (`export let`, `$:`, Stores mit `$`-Präfix).
- **Store-Muster** wie `frontend/src/lib/stores/events.svelte.ts`: Klasse mit `$state`, `KEY = Symbol('name')`, `setXContext()` / `getXContext()`, `load()`, `subscribe()`, `destroy()`, `scoped`-Getter über `teams.activeId`.
- **Alle Collections sind teamgebunden.** Jede neue Collection trägt ein `team`-Feld; Lese-Rules lauten `team.users.id ?= @request.auth.id`.
- **PocketBase-Standardfelder:** Jede Collection bekommt `created` und `updated` als `autodate` (PocketBase legt sie nicht selbst an).
- **Migration ohne Seed-Daten** — Standard-Flags entstehen per Knopfdruck im UI, sonst kämen sie bei jedem Containerstart zurück.
- **Nach Schema-Änderung:** `npm run generate-pocketbase-types` ausführen und `frontend/src/lib/types.d.ts` mitcommitten.
- Sprache aller Kommentare, UI-Texte und Commit-Messages: **Deutsch mit korrekten Umlauten**.
- **Kein `git push`** — nur auf ausdrückliche Anweisung.
- Die produktive Instanz `https://api.stammtisch-hersbruck.de` wird **nicht** verändert. Entwickelt und getestet wird gegen das lokale Backend (`cd backend && docker compose up -d`, dann `PUBLIC_PB_URL=http://127.0.0.1:8090` in `frontend/.env`).
## Dateiübersicht
| Datei | Verantwortung |
|---|---|
| `backend/pb_migrations/<ts>_created_trails.js` | Die fünf Collections, Felder, Rules |
| `frontend/src/lib/gpx.ts` | GPX parsen, Länge/Höhenmeter/Bounds, Vereinfachung |
| `frontend/src/lib/gpx.test.ts` | Tests dazu |
| `frontend/src/lib/stores/trails.svelte.ts` | Trails: CRUD, Patenschaft, Status |
| `frontend/src/lib/stores/trailVersions.svelte.ts` | Versionen: Upload, Aktivieren |
| `frontend/src/lib/stores/trailFlags.svelte.ts` | Flag-Typen, `seedDefaults()` |
| `frontend/src/lib/stores/trailMarkers.svelte.ts` | Marker, `toggleResolved` |
| `frontend/src/lib/components/TrailMap.svelte` | Karte — nur Props, kein Store |
| `frontend/src/lib/components/ElevationProfile.svelte` | Höhenprofil als SVG |
| `frontend/src/routes/dashboard/trails/+page.svelte` | Liste + Übersichtskarte |
| `frontend/src/routes/dashboard/trails/[id]/+page.svelte` | Detailseite |
| `frontend/src/routes/dashboard/settings/flags/+page.svelte` | Flag-Verwaltung |
| `frontend/src/routes/dashboard/+layout.svelte` | Stores registrieren, Navigation |
## Hinweis zur Reihenfolge
Task 1 (Migration) und Task 2 (GPX-Parser) sind unabhängig voneinander. Alle
weiteren Tasks setzen auf beiden auf. Task 35 (Stores) müssen vor den
UI-Tasks 69 liegen.
---
### Task 1: Migration für die fünf Collections
**Files:**
- Create: `backend/pb_migrations/1754500000_created_trails.js`
- Modify: `frontend/src/lib/types.d.ts` (generiert)
**Interfaces:**
- Consumes: nichts
- Produces: Collections `trails`, `trail_versions`, `trail_flags`, `trail_markers`, `trail_comments`. Typen `TrailsResponse`, `TrailVersionsResponse`, `TrailFlagsResponse`, `TrailMarkersResponse`, `TrailCommentsResponse` sowie `TrailsStatusOptions` und `TrailFlagsSeverityOptions` in `$lib/types`.
- [ ] **Step 1: Migration schreiben**
Zwei Besonderheiten: Der Zirkelbezug `trails.current``trail_versions` wird
in einem zweiten Schritt ergänzt, und es gibt keine Seed-Daten.
Datei `backend/pb_migrations/1754500000_created_trails.js`:
```js
/// <reference path="../pb_data/types.d.ts" />
// Trails: GPX-Tracks mit Versionen, verorteten Markern und Kommentaren.
//
// Reihenfolge ist wichtig: trails.current zeigt auf trail_versions,
// trail_versions.trail zeigt zurück auf trails. Deshalb wird trails zuerst
// OHNE current angelegt, dann trail_versions, und current danach ergänzt.
migrate((app) => {
const teams = app.findCollectionByNameOrId('teams')
const users = app.findCollectionByNameOrId('users')
// Für jede Collection gleich: Standardfelder, die PocketBase nicht
// selbst anlegt.
const timestamps = [
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
]
const teamScoped = (name) => ({
name,
type: 'relation',
required: true,
maxSelect: 1,
collectionId: teams.id,
cascadeDelete: true,
})
// --- trails (noch ohne current) --------------------------------------
const trails = new Collection({
type: 'base',
name: 'trails',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
// Bearbeiten dürfen Paten und Team-Admins
updateRule:
'stewards.id ?= @request.auth.id' +
' || team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id',
// Löschen nur Admins — am Trail hängen fremde Marker und Kommentare
deleteRule:
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id',
fields: [
teamScoped('team'),
{ name: 'name', type: 'text', required: true, max: 200 },
{ name: 'description', type: 'editor' },
{
name: 'status',
type: 'select',
maxSelect: 1,
values: ['offen', 'eingeschraenkt', 'gesperrt'],
},
{
name: 'stewards',
type: 'relation',
maxSelect: 0,
collectionId: users.id,
cascadeDelete: false,
},
{
name: 'event',
type: 'relation',
maxSelect: 1,
collectionId: app.findCollectionByNameOrId('events').id,
cascadeDelete: false,
},
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(trails)
// --- trail_versions ---------------------------------------------------
const versions = new Collection({
type: 'base',
name: 'trail_versions',
listRule: 'trail.team.users.id ?= @request.auth.id',
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',
// Versionen sind unveränderlich — eine Korrektur ist eine neue Version
updateRule: null,
deleteRule:
'trail.team.owner.id ?= @request.auth.id' +
' || trail.team.admins.id ?= @request.auth.id',
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
{
name: 'gpx',
type: 'file',
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ['application/gpx+xml', 'application/xml', 'text/xml'],
},
{ name: 'geojson', type: 'json', maxSize: 5000000 },
{ name: 'elevation', type: 'json', maxSize: 2000000 },
{ name: 'bounds', type: 'json', maxSize: 1000 },
{ name: 'distance_m', type: 'number' },
{ name: 'ascent_m', type: 'number' },
{ name: 'note', type: 'text', max: 500 },
{
name: 'uploaded_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(versions)
// --- trails.current nachtragen ---------------------------------------
trails.fields.add(new Field({
name: 'current',
type: 'relation',
maxSelect: 1,
collectionId: versions.id,
cascadeDelete: false,
}))
app.save(trails)
// --- trail_flags ------------------------------------------------------
const adminOnly =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
const flags = new Collection({
type: 'base',
name: 'trail_flags',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: adminOnly,
updateRule: adminOnly,
deleteRule: adminOnly,
fields: [
teamScoped('team'),
{ name: 'label', type: 'text', required: true, max: 60 },
{ name: 'icon', type: 'text', max: 40 },
{ name: 'color', type: 'text', max: 9 },
{
name: 'severity',
type: 'select',
maxSelect: 1,
values: ['info', 'warnung', 'kritisch'],
},
...timestamps,
],
})
app.save(flags)
// --- trail_markers ----------------------------------------------------
// Anlegen darf jedes Team-Mitglied; ändern nur der Ersteller, Paten
// oder Admins.
const ownerOrSteward =
'created_by.id ?= @request.auth.id' +
' || trail.stewards.id ?= @request.auth.id' +
' || team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
const markers = new Collection({
type: 'base',
name: 'trail_markers',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: ownerOrSteward,
deleteRule: ownerOrSteward,
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
teamScoped('team'),
{
name: 'flag',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: flags.id,
cascadeDelete: false,
},
{ name: 'lat', type: 'number', required: true },
{ name: 'lng', type: 'number', required: true },
{ name: 'note', type: 'text', max: 500 },
{ name: 'resolved', type: 'bool' },
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(markers)
// --- trail_comments ---------------------------------------------------
const comments = new Collection({
type: 'base',
name: 'trail_comments',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: ownerOrSteward,
deleteRule: ownerOrSteward,
fields: [
{
name: 'trail',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: trails.id,
cascadeDelete: true,
},
teamScoped('team'),
{ name: 'text', type: 'text', required: true, max: 2000 },
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
...timestamps,
],
})
app.save(comments)
}, (app) => {
// Rückwärts in umgekehrter Reihenfolge, damit keine Relation ins Leere
// zeigt. trails zuletzt, weil alle anderen darauf verweisen.
for (const name of [
'trail_comments',
'trail_markers',
'trail_flags',
'trail_versions',
'trails',
]) {
try {
app.delete(app.findCollectionByNameOrId(name))
} catch {
// Bereits entfernt — nichts zu tun.
}
}
})
```
- [ ] **Step 2: Syntax prüfen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
node --check backend/pb_migrations/1754500000_created_trails.js && echo "Syntax ok"
```
Erwartet: „Syntax ok". (`migrate`, `Collection` und `Field` sind erst zur Laufzeit definiert — das stört `node --check` nicht.)
- [ ] **Step 3: Migration im Container anwenden**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/backend
docker compose up -d --build
until curl -s http://127.0.0.1:8090/api/health >/dev/null 2>&1; do sleep 1; done
sleep 3
docker compose logs --no-log-prefix 2>&1 | tail -10
```
Erwartet: Keine Zeile mit `failed to apply migration`. Erscheint eine, ist die Migration fehlerhaft — Fehlermeldung lesen und beheben, nicht die DB löschen.
- [ ] **Step 4: Collections und Rules prüfen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/backend
set -a && . ./.env && set +a
TOKEN=$(curl -s -X POST http://127.0.0.1:8090/api/collections/_superusers/auth-with-password \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$SUPERUSER_EMAIL\",\"password\":\"$SUPERUSER_PASSWORD\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))")
curl -s -H "Authorization: $TOKEN" "http://127.0.0.1:8090/api/collections?perPage=200" \
| python3 -c "
import sys, json
d = json.load(sys.stdin)
want = ['trails','trail_versions','trail_flags','trail_markers','trail_comments']
have = {c['name']: c for c in d['items']}
for n in want:
if n not in have:
print(f' {n}: FEHLT'); continue
c = have[n]
print(f\" {n}: {len(c['fields'])} Felder, update={c.get('updateRule')!r}\")
missing = [n for n in want if n not in have]
print('OK' if not missing else 'FEHLEN: ' + str(missing))
"
```
Erwartet: Alle fünf Collections, letzte Zeile `OK`. Bei `trail_versions` muss `update=None` stehen (Versionen sind unveränderlich).
- [ ] **Step 5: Zirkelbezug prüfen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/backend
curl -s -H "Authorization: $TOKEN" "http://127.0.0.1:8090/api/collections/trails" \
| python3 -c "
import sys, json
c = json.load(sys.stdin)
cur = [f for f in c['fields'] if f['name'] == 'current']
print('current-Feld vorhanden:', bool(cur))
if cur: print(' zeigt auf collectionId:', cur[0].get('collectionId'))
"
```
Erwartet: `current-Feld vorhanden: True` mit einer `collectionId`. Fehlt es, wurde Step „trails.current nachtragen" nicht ausgeführt.
- [ ] **Step 6: Typen generieren**
Die Typen kommen aus der Migration im Repo, nicht aus einer laufenden Instanz.
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run generate-pocketbase-types
grep -E "^\t(Trails|TrailVersions|TrailFlags|TrailMarkers|TrailComments) = " src/lib/types.d.ts
```
Erwartet: Alle fünf Einträge erscheinen.
Wichtig: `scripts/schema-to-json.mjs` nimmt die **jüngste** Migration mit einem `const collections = ` — das ist weiterhin die Snapshot-Migration `1754400000_init_schema.js`, nicht die neue. Die neue Migration verwendet `new Collection(...)` statt eines Collections-Arrays und wird vom Script übersprungen. **Deshalb liefert der Aufruf hier noch nicht die neuen Typen.** Statt das Script umzubauen, wird der Snapshot aktualisiert:
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/backend
curl -s -H "Authorization: $TOKEN" "http://127.0.0.1:8090/api/collections?perPage=200" -o /tmp/local.json
cd /home/dne/Projekte/stammtisch-hersbruck.de
python3 - <<'PY'
import json, re
with open('/tmp/local.json') as f:
items = json.load(f)['items']
keep = [c for c in items if not c['name'].startswith('_')]
order = {'users':0,'teams':1,'events':2,'runs':3,'riders':4,'times':5,
'trails':6,'trail_versions':7,'trail_flags':8,'trail_markers':9,
'trail_comments':10}
keep.sort(key=lambda c: order.get(c['name'], 99))
path = 'backend/pb_migrations/1754400000_init_schema.js'
src = open(path).read()
marker = 'const collections = '
start = src.index(marker) + len(marker)
depth = 0
for i in range(start, len(src)):
if src[i] == '[': depth += 1
elif src[i] == ']':
depth -= 1
if depth == 0:
end = i + 1
break
new = json.dumps(keep, indent=4, ensure_ascii=False)
open(path, 'w').write(src[:start] + new + src[end:])
print('Snapshot aktualisiert:', [c['name'] for c in keep])
PY
cd frontend && npm run generate-pocketbase-types
grep -E "^\t(Trails|TrailVersions|TrailFlags|TrailMarkers|TrailComments) = " src/lib/types.d.ts
```
Erwartet: Der Snapshot enthält jetzt elf Collections, und alle fünf neuen Typen erscheinen.
- [ ] **Step 7: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add backend/pb_migrations/ frontend/src/lib/types.d.ts
git status --short
git commit -m "feat: Collections für Trails, Versionen, Flags, Marker und Kommentare"
```
---
### Task 2: GPX-Parser
**Files:**
- Create: `frontend/src/lib/gpx.ts`
- Create: `frontend/src/lib/gpx.test.ts`
- Modify: `frontend/package.json` (Test-Runner)
**Interfaces:**
- Consumes: nichts
- Produces:
```ts
export type TrackPoint = { lat: number; lng: number; ele: number | null }
export type ParsedGpx = {
points: TrackPoint[] // vereinfacht, für die Anzeige
geojson: { type: 'LineString'; coordinates: [number, number][] }
elevation: { d: number; ele: number }[] // d = Distanz in Metern
bounds: [[number, number], [number, number]] // [[minLng,minLat],[maxLng,maxLat]]
distance_m: number
ascent_m: number
}
export function parseGpx(xml: string): ParsedGpx // wirft bei ungültigem GPX
export function haversine(a: TrackPoint, b: TrackPoint): number
export function simplify(points: TrackPoint[], maxPoints?: number): TrackPoint[]
```
- [ ] **Step 1: Test-Runner einrichten**
Das Projekt hat noch keinen. Vitest passt zu Vite und braucht keine eigene Konfiguration.
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm install -D vitest
```
Dann in `package.json` bei `"scripts"` ergänzen (nach `"check:watch"`):
```json
"test": "vitest run",
"test:watch": "vitest",
```
- [ ] **Step 2: Die fehlschlagenden Tests schreiben**
Datei `frontend/src/lib/gpx.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import { parseGpx, haversine, simplify } from './gpx'
// Zwei Punkte in Hersbruck, rund 1,4 km auseinander.
const SAMPLE = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Testtrail</name><trkseg>
<trkpt lat="49.5100" lon="11.4300"><ele>340</ele></trkpt>
<trkpt lat="49.5110" lon="11.4320"><ele>350</ele></trkpt>
<trkpt lat="49.5120" lon="11.4340"><ele>345</ele></trkpt>
<trkpt lat="49.5130" lon="11.4360"><ele>400</ele></trkpt>
</trkseg></trk>
</gpx>`
describe('haversine', () => {
it('misst die Distanz zwischen zwei Punkten in Metern', () => {
const d = haversine(
{ lat: 49.51, lng: 11.43, ele: null },
{ lat: 49.52, lng: 11.43, ele: null },
)
// 0,01 Grad Breite sind rund 1112 m
expect(d).toBeGreaterThan(1100)
expect(d).toBeLessThan(1125)
})
it('liefert 0 für identische Punkte', () => {
const p = { lat: 49.51, lng: 11.43, ele: null }
expect(haversine(p, p)).toBe(0)
})
})
describe('parseGpx', () => {
it('liest alle Trackpunkte', () => {
const r = parseGpx(SAMPLE)
expect(r.points).toHaveLength(4)
expect(r.points[0]).toEqual({ lat: 49.51, lng: 11.43, ele: 340 })
})
it('berechnet eine plausible Streckenlänge', () => {
const r = parseGpx(SAMPLE)
expect(r.distance_m).toBeGreaterThan(400)
expect(r.distance_m).toBeLessThan(700)
})
it('zählt nur Anstiege über der Rauschschwelle', () => {
const r = parseGpx(SAMPLE)
// +10 (340→350), -5 (350→345, zählt nicht), +55 (345→400) = 65
expect(r.ascent_m).toBe(65)
})
it('erfasst einen gleichmäßigen Anstieg auch in feinen Schritten', () => {
// 300 -> 303 in 1-m-Schritten ist kein Rauschen, sondern ein echter
// Anstieg von 3 m — nur fein aufgezeichnet. Der Bezugspunkt bleibt
// stehen, bis die Schwelle erreicht ist, deshalb wird er erfasst.
const fine = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"><ele>300</ele></trkpt>
<trkpt lat="49.511" lon="11.431"><ele>301</ele></trkpt>
<trkpt lat="49.512" lon="11.432"><ele>302</ele></trkpt>
<trkpt lat="49.513" lon="11.433"><ele>303</ele></trkpt>
</trkseg></trk></gpx>`
expect(parseGpx(fine).ascent_m).toBe(3)
})
it('ignoriert Rauschen, das um denselben Wert schwankt', () => {
// Auf und ab um höchstens 2 m ohne echten Höhengewinn — genau das,
// wogegen die Schwelle schützt.
const noisy = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"><ele>300</ele></trkpt>
<trkpt lat="49.511" lon="11.431"><ele>302</ele></trkpt>
<trkpt lat="49.512" lon="11.432"><ele>300</ele></trkpt>
<trkpt lat="49.513" lon="11.433"><ele>301</ele></trkpt>
<trkpt lat="49.514" lon="11.434"><ele>299</ele></trkpt>
<trkpt lat="49.515" lon="11.435"><ele>300</ele></trkpt>
</trkseg></trk></gpx>`
expect(parseGpx(noisy).ascent_m).toBe(0)
})
it('liefert die Bounding-Box als [[minLng,minLat],[maxLng,maxLat]]', () => {
const r = parseGpx(SAMPLE)
expect(r.bounds).toEqual([[11.43, 49.51], [11.436, 49.513]])
})
it('liefert GeoJSON mit [lng, lat] in dieser Reihenfolge', () => {
const r = parseGpx(SAMPLE)
expect(r.geojson.type).toBe('LineString')
expect(r.geojson.coordinates[0]).toEqual([11.43, 49.51])
})
it('liefert ein Höhenprofil mit aufsummierter Distanz', () => {
const r = parseGpx(SAMPLE)
expect(r.elevation).toHaveLength(4)
expect(r.elevation[0]).toEqual({ d: 0, ele: 340 })
expect(r.elevation[3].d).toBeCloseTo(r.distance_m, 0)
})
it('kommt mit fehlenden Höhenangaben zurecht', () => {
const noEle = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"/>
<trkpt lat="49.511" lon="11.431"/>
</trkseg></trk></gpx>`
const r = parseGpx(noEle)
expect(r.points[0].ele).toBeNull()
expect(r.ascent_m).toBe(0)
expect(r.elevation).toHaveLength(0)
})
it('greift auf Routenpunkte zurück, wenn Trackpunkte fehlen', () => {
const route = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><rte>
<rtept lat="49.510" lon="11.430"><ele>300</ele></rtept>
<rtept lat="49.511" lon="11.431"><ele>310</ele></rtept>
</rte></gpx>`
expect(parseGpx(route).points).toHaveLength(2)
})
it('wirft bei einer Datei ohne Punkte', () => {
const empty = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>`
expect(() => parseGpx(empty)).toThrow(/keine Punkte/i)
})
it('wirft bei ungültigem XML', () => {
expect(() => parseGpx('kein xml')).toThrow(/ung(ü|ue)ltig/i)
})
})
describe('simplify', () => {
it('lässt kurze Punktfolgen unangetastet', () => {
const pts = [
{ lat: 49.51, lng: 11.43, ele: null },
{ lat: 49.52, lng: 11.44, ele: null },
]
expect(simplify(pts, 100)).toHaveLength(2)
})
it('reduziert lange Punktfolgen auf das Maximum', () => {
const pts = Array.from({ length: 5000 }, (_, i) => ({
lat: 49.5 + i * 0.0001,
lng: 11.4 + Math.sin(i / 50) * 0.01,
ele: null,
}))
const out = simplify(pts, 500)
expect(out.length).toBeLessThanOrEqual(500)
expect(out.length).toBeGreaterThan(10)
})
it('behält Anfang und Ende bei', () => {
const pts = Array.from({ length: 1000 }, (_, i) => ({
lat: 49.5 + i * 0.001,
lng: 11.4 + i * 0.001,
ele: null,
}))
const out = simplify(pts, 50)
expect(out[0]).toEqual(pts[0])
expect(out[out.length - 1]).toEqual(pts[pts.length - 1])
})
})
```
- [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm test
```
Erwartet: FAIL, „Failed to resolve import './gpx'" — die Datei gibt es noch nicht.
- [ ] **Step 4: Den Parser schreiben**
Datei `frontend/src/lib/gpx.ts`:
```ts
/**
* GPX-Verarbeitung im Browser.
*
* Das Backend bleibt reines PocketBase ohne Hooks: Beim Upload liest das
* Frontend die Datei, rechnet Länge, Höhenmeter und Bounding-Box aus und
* schickt Originaldatei plus abgeleitete Werte gemeinsam an die API.
*/
export type TrackPoint = { lat: number; lng: number; ele: number | null }
export type ParsedGpx = {
points: TrackPoint[]
geojson: { type: 'LineString'; coordinates: [number, number][] }
elevation: { d: number; ele: number }[]
bounds: [[number, number], [number, number]]
distance_m: number
ascent_m: number
}
/** Erdradius in Metern */
const R = 6_371_000
/**
* Anstiege unterhalb dieser Schwelle gelten als GPS-Rauschen. Ohne sie
* summieren sich Messfehler zu absurden Höhenmetern — ein flacher Waldweg
* käme leicht auf mehrere hundert Meter.
*/
const ELE_NOISE_M = 3
/** Distanz zweier Punkte in Metern (Haversine). */
export function haversine(a: TrackPoint, b: TrackPoint): number {
const toRad = (d: number) => (d * Math.PI) / 180
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const h =
Math.sin(dLat / 2) ** 2 +
Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
return 2 * R * Math.asin(Math.sqrt(h))
}
/**
* Douglas-Peucker: entfernt Punkte, die kaum von der Verbindungslinie ihrer
* Nachbarn abweichen. Die Toleranz wird verdoppelt, bis das Ergebnis unter
* maxPoints liegt — so bleibt die Form erhalten, während die Datenmenge
* beherrschbar wird.
*/
export function simplify(points: TrackPoint[], maxPoints = 2000): TrackPoint[] {
if (points.length <= maxPoints) return points
let tolerance = 0.00001
let out = points
// Obergrenze gegen Endlosschleifen bei entarteten Daten
for (let i = 0; i < 30 && out.length > maxPoints; i++) {
out = douglasPeucker(points, tolerance)
tolerance *= 2
}
return out
}
function douglasPeucker(pts: TrackPoint[], tolerance: number): TrackPoint[] {
if (pts.length < 3) return pts
let maxDist = 0
let index = 0
for (let i = 1; i < pts.length - 1; i++) {
const d = perpendicularDistance(pts[i], pts[0], pts[pts.length - 1])
if (d > maxDist) {
maxDist = d
index = i
}
}
if (maxDist <= tolerance) return [pts[0], pts[pts.length - 1]]
const left = douglasPeucker(pts.slice(0, index + 1), tolerance)
const right = douglasPeucker(pts.slice(index), tolerance)
return [...left.slice(0, -1), ...right]
}
/**
* Abstand eines Punktes zur Geraden durch start und end — in Grad, nicht in
* Metern. Für den Vergleich innerhalb eines Tracks genügt das und spart die
* teure Projektion.
*/
function perpendicularDistance(p: TrackPoint, start: TrackPoint, end: TrackPoint): number {
const dx = end.lng - start.lng
const dy = end.lat - start.lat
if (dx === 0 && dy === 0) {
return Math.hypot(p.lng - start.lng, p.lat - start.lat)
}
const t =
((p.lng - start.lng) * dx + (p.lat - start.lat) * dy) / (dx * dx + dy * dy)
const clamped = Math.max(0, Math.min(1, t))
return Math.hypot(
p.lng - (start.lng + clamped * dx),
p.lat - (start.lat + clamped * dy),
)
}
/**
* Liest eine GPX-Datei. Wirft mit einer für Nutzer verständlichen Meldung,
* wenn die Datei kein gültiges XML ist oder keine Punkte enthält.
*/
export function parseGpx(xml: string): ParsedGpx {
const doc = new DOMParser().parseFromString(xml, 'application/xml')
if (doc.querySelector('parsererror')) {
throw new Error('Die Datei ist kein gültiges XML.')
}
// Manche Programme exportieren Routen (rtept) statt Tracks (trkpt).
let nodes = Array.from(doc.getElementsByTagName('trkpt'))
if (nodes.length === 0) {
nodes = Array.from(doc.getElementsByTagName('rtept'))
}
if (nodes.length === 0) {
throw new Error('Die GPX-Datei enthält keine Punkte.')
}
const points: TrackPoint[] = []
for (const n of nodes) {
const lat = Number(n.getAttribute('lat'))
const lng = Number(n.getAttribute('lon'))
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
const eleText = n.getElementsByTagName('ele')[0]?.textContent
const ele = eleText != null && eleText.trim() !== '' ? Number(eleText) : null
points.push({ lat, lng, ele: Number.isFinite(ele as number) ? ele : null })
}
if (points.length === 0) {
throw new Error('Die GPX-Datei enthält keine Punkte.')
}
// Länge, Höhenprofil und Höhenmeter in einem Durchlauf
let distance = 0
let ascent = 0
const elevation: { d: number; ele: number }[] = []
let lastCountedEle: number | null = points[0].ele
if (points[0].ele != null) elevation.push({ d: 0, ele: points[0].ele })
for (let i = 1; i < points.length; i++) {
distance += haversine(points[i - 1], points[i])
const ele = points[i].ele
if (ele == null) continue
elevation.push({ d: Math.round(distance), ele })
if (lastCountedEle == null) {
lastCountedEle = ele
} else if (ele - lastCountedEle >= ELE_NOISE_M) {
ascent += ele - lastCountedEle
lastCountedEle = ele
} else if (lastCountedEle - ele >= ELE_NOISE_M) {
// Abstieg: neuer Bezugspunkt, aber nicht gezählt
lastCountedEle = ele
}
}
const lats = points.map((p) => p.lat)
const lngs = points.map((p) => p.lng)
const simplified = simplify(points)
return {
points: simplified,
geojson: {
type: 'LineString',
coordinates: simplified.map((p) => [p.lng, p.lat] as [number, number]),
},
elevation,
bounds: [
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)],
],
distance_m: Math.round(distance),
ascent_m: Math.round(ascent),
}
}
```
- [ ] **Step 5: Tests laufen lassen — sie müssen jetzt bestehen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm test
```
Erwartet: alle Tests grün.
Falls der Test „liest alle Trackpunkte" scheitert, weil `DOMParser` fehlt: Vitest läuft standardmäßig in Node ohne DOM. Dann `npm install -D jsdom` und in `vite.config.ts` bei `defineConfig` ergänzen:
```ts
test: {
environment: 'jsdom',
},
```
- [ ] **Step 6: Typprüfung**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -3
```
Erwartet: 0 Fehler, 0 Warnungen.
- [ ] **Step 7: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/lib/gpx.ts frontend/src/lib/gpx.test.ts frontend/package.json frontend/package-lock.json frontend/vite.config.ts
git commit -m "feat: GPX-Parser mit Länge, Höhenmetern und Streckenvereinfachung"
```
---
### Task 3: Stores für Trails und Versionen
**Files:**
- Create: `frontend/src/lib/stores/trails.svelte.ts`
- Create: `frontend/src/lib/stores/trailVersions.svelte.ts`
**Interfaces:**
- Consumes: `parseGpx` aus `$lib/gpx`; `getTeamContext()` aus `$lib/stores/teams.svelte`; `api` aus `$lib/stores/pocketbase.svelte`
- Produces:
```ts
// trails.svelte.ts
export class TrailStore {
records: TrailsResponse[]
loading: boolean
error: string | null
get scoped(): TrailsResponse[]
getById(id: string): TrailsResponse | undefined
async load(): Promise<void>
subscribe(): void
destroy(): void
async create(data: { name: string; description?: string; status?: string }): Promise<TrailsResponse>
async edit(id: string, data: Partial<TrailsResponse>): Promise<TrailsResponse>
async remove(id: string): Promise<void>
async setStewards(id: string, userIds: string[]): Promise<TrailsResponse>
canEdit(trail: TrailsResponse): boolean
}
export function setTrailContext(): TrailStore
export function getTrailContext(): TrailStore
// trailVersions.svelte.ts
export class TrailVersionStore {
records: TrailVersionsResponse[]
loading: boolean
error: string | null
byTrail(trailId: string): TrailVersionsResponse[]
async load(): Promise<void>
subscribe(): void
destroy(): void
async upload(trailId: string, file: File, note: string): Promise<TrailVersionsResponse>
async activate(trailId: string, versionId: string): Promise<void>
async remove(id: string): Promise<void>
}
export function setTrailVersionContext(): TrailVersionStore
export function getTrailVersionContext(): TrailVersionStore
```
- [ ] **Step 1: `trails.svelte.ts` schreiben**
Aufbau exakt nach dem Muster von `events.svelte.ts`.
```ts
import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import type { TrailsResponse } from '$lib/types'
const KEY = Symbol('trail')
export class TrailStore {
records = $state<TrailsResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
/** Records gefiltert nach aktivem Team */
get scoped(): TrailsResponse[] {
const teamId = this.teams.activeId
if (!teamId) return []
return this.records.filter((r) => r.team === teamId)
}
getById(id: string): TrailsResponse | undefined {
return this.records.find((r) => r.id === id)
}
/**
* Bearbeiten dürfen Trail-Paten sowie Owner und Admins des Teams —
* dieselbe Regel wie in der updateRule der Collection. Die Prüfung hier
* blendet nur UI aus; durchgesetzt wird sie serverseitig.
*/
canEdit(trail: TrailsResponse): boolean {
const uid = auth.user?.id
if (!uid) return false
const team = this.teams.records.find((t) => t.id === trail.team)
const stewards = (trail.stewards ?? []) as string[]
return (
stewards.includes(uid) ||
team?.owner === uid ||
((team?.admins ?? []) as string[]).includes(uid)
)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('trails').getFullList({
sort: 'name',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Trails'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('trails').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('trails subscribe failed:', e))
}
async create(data: { name: string; description?: string; status?: string }) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
const uid = auth.user?.id
// Wer anlegt, wird automatisch erster Pate — sonst könnte niemand
// den frisch angelegten Trail bearbeiten.
return await api.collection('trails').create({
...data,
status: data.status ?? 'offen',
team: teamId,
created_by: uid,
stewards: uid ? [uid] : [],
})
}
async edit(id: string, data: Partial<TrailsResponse>) {
return await api.collection('trails').update(id, data)
}
async setStewards(id: string, userIds: string[]) {
return await api.collection('trails').update(id, { stewards: userIds })
}
async remove(id: string) {
await api.collection('trails').delete(id)
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTrailContext() {
const store = new TrailStore()
setContext(KEY, store)
return store
}
export function getTrailContext(): TrailStore {
return getContext<TrailStore>(KEY)
}
```
- [ ] **Step 2: `trailVersions.svelte.ts` schreiben**
```ts
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { parseGpx } from '$lib/gpx'
import type { TrailVersionsResponse } from '$lib/types'
const KEY = Symbol('trailVersion')
/** Größer als das maxSize des gpx-Feldes wäre serverseitig ohnehin abgelehnt. */
const MAX_GPX_BYTES = 10 * 1024 * 1024
export class TrailVersionStore {
records = $state<TrailVersionsResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
/** Versionen eines Trails, jüngste zuerst */
byTrail(trailId: string): TrailVersionsResponse[] {
return this.records
.filter((r) => r.trail === trailId)
.sort((a, b) => (a.created < b.created ? 1 : -1))
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('trail_versions').getFullList({
sort: '-created',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Versionen'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('trail_versions').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records]
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('trail_versions subscribe failed:', e))
}
/**
* Liest die GPX-Datei im Browser, legt eine Version an und macht sie zur
* aktiven. Die Originaldatei wird mitgespeichert, damit sie später
* heruntergeladen werden kann.
*/
async upload(trailId: string, file: File, note: string) {
if (file.size > MAX_GPX_BYTES) {
throw new Error('Die Datei ist größer als 10 MB.')
}
const text = await file.text()
const parsed = parseGpx(text) // wirft mit verständlicher Meldung
const form = new FormData()
form.append('trail', trailId)
form.append('gpx', file)
form.append('geojson', JSON.stringify(parsed.geojson))
form.append('elevation', JSON.stringify(parsed.elevation))
form.append('bounds', JSON.stringify(parsed.bounds))
form.append('distance_m', String(parsed.distance_m))
form.append('ascent_m', String(parsed.ascent_m))
form.append('note', note)
if (auth.user?.id) form.append('uploaded_by', auth.user.id)
const version = await api.collection('trail_versions').create(form)
// Eine frisch hochgeladene Version ist immer die aktive.
await api.collection('trails').update(trailId, { current: version.id })
return version
}
/**
* Schaltet auf eine ältere Version zurück. Prüft vorher, dass die Version
* zu diesem Trail gehört — trails.current könnte sonst auf eine fremde
* Version zeigen.
*/
async activate(trailId: string, versionId: string) {
const version = this.records.find((r) => r.id === versionId)
if (!version) throw new Error('Version nicht gefunden')
if (version.trail !== trailId) {
throw new Error('Die Version gehört nicht zu diesem Trail')
}
await api.collection('trails').update(trailId, { current: versionId })
}
async remove(id: string) {
await api.collection('trail_versions').delete(id)
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTrailVersionContext() {
const store = new TrailVersionStore()
setContext(KEY, store)
return store
}
export function getTrailVersionContext(): TrailVersionStore {
return getContext<TrailVersionStore>(KEY)
}
```
- [ ] **Step 3: Prüfen, dass `auth` exportiert wird**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
grep -n "export const auth\|export {.*auth" src/lib/stores/pocketbase.svelte.ts
```
Erwartet: eine Zeile mit `auth`. Fehlt sie, heißt der Export anders — dann in beiden neuen Dateien den Import entsprechend anpassen.
- [ ] **Step 4: Typprüfung**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5
```
Erwartet: 0 Fehler, 0 Warnungen.
- [ ] **Step 5: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/lib/stores/trails.svelte.ts frontend/src/lib/stores/trailVersions.svelte.ts
git commit -m "feat: Stores für Trails und GPX-Versionen"
```
---
### Task 4: Stores für Flags und Marker
**Files:**
- Create: `frontend/src/lib/stores/trailFlags.svelte.ts`
- Create: `frontend/src/lib/stores/trailMarkers.svelte.ts`
**Interfaces:**
- Consumes: `getTeamContext()`, `api`, `auth`
- Produces:
```ts
// trailFlags.svelte.ts
export const DEFAULT_FLAGS: { label: string; icon: string; color: string; severity: string }[]
export class TrailFlagStore {
records: TrailFlagsResponse[]
loading: boolean
error: string | null
get scoped(): TrailFlagsResponse[]
getById(id: string): TrailFlagsResponse | undefined
async load(): Promise<void>
subscribe(): void
destroy(): void
async create(data: { label: string; icon: string; color: string; severity: string }): Promise<TrailFlagsResponse>
async edit(id: string, data: Partial<TrailFlagsResponse>): Promise<TrailFlagsResponse>
async remove(id: string): Promise<void>
async seedDefaults(): Promise<number>
}
export function setTrailFlagContext(): TrailFlagStore
export function getTrailFlagContext(): TrailFlagStore
// trailMarkers.svelte.ts
export class TrailMarkerStore {
records: TrailMarkersResponse[]
loading: boolean
error: string | null
byTrail(trailId: string): TrailMarkersResponse[]
openByTrail(trailId: string): TrailMarkersResponse[]
async load(): Promise<void>
subscribe(): void
destroy(): void
async create(data: { trail: string; flag: string; lat: number; lng: number; note?: string }): Promise<TrailMarkersResponse>
async edit(id: string, data: Partial<TrailMarkersResponse>): Promise<TrailMarkersResponse>
async toggleResolved(id: string): Promise<void>
async remove(id: string): Promise<void>
}
export function setTrailMarkerContext(): TrailMarkerStore
export function getTrailMarkerContext(): TrailMarkerStore
```
- [ ] **Step 1: `trailFlags.svelte.ts` schreiben**
```ts
import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import type { TrailFlagsResponse } from '$lib/types'
const KEY = Symbol('trailFlag')
/**
* Vorschlag für einen Standardsatz. Bewusst NICHT in der Migration: Seed-Daten
* dort kämen bei jedem Containerstart zurück, auch nachdem sie jemand gelöscht
* hat. Stattdessen legt sie die Flag-Verwaltung auf Knopfdruck an.
*
* icon: Name eines Lucide-Icons (lucide-svelte)
*/
export const DEFAULT_FLAGS = [
{ label: 'Baum quer', icon: 'TreePine', color: '#ca8a04', severity: 'warnung' },
{ label: 'Verblockt', icon: 'Blocks', color: '#ea580c', severity: 'warnung' },
{ label: 'Erosion', icon: 'Waves', color: '#a16207', severity: 'warnung' },
{ label: 'Sperrung', icon: 'Ban', color: '#dc2626', severity: 'kritisch' },
{ label: 'Bauarbeiten', icon: 'Construction', color: '#dc2626', severity: 'kritisch' },
{ label: 'Hinweis', icon: 'Info', color: '#2563eb', severity: 'info' },
]
export class TrailFlagStore {
records = $state<TrailFlagsResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
get scoped(): TrailFlagsResponse[] {
const teamId = this.teams.activeId
if (!teamId) return []
return this.records.filter((r) => r.team === teamId)
}
getById(id: string): TrailFlagsResponse | undefined {
return this.records.find((r) => r.id === id)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('trail_flags').getFullList({
sort: 'label',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Flags'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('trail_flags').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('trail_flags subscribe failed:', e))
}
async create(data: { label: string; icon: string; color: string; severity: string }) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
return await api.collection('trail_flags').create({ ...data, team: teamId })
}
async edit(id: string, data: Partial<TrailFlagsResponse>) {
return await api.collection('trail_flags').update(id, data)
}
async remove(id: string) {
await api.collection('trail_flags').delete(id)
}
/**
* Legt die Standard-Flags an, die noch fehlen. Vorhandene bleiben
* unangetastet — der Knopf lässt sich also gefahrlos mehrfach drücken.
* Gibt die Anzahl neu angelegter Flags zurück.
*/
async seedDefaults(): Promise<number> {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
const existing = new Set(this.scoped.map((f) => f.label))
let created = 0
for (const flag of DEFAULT_FLAGS) {
if (existing.has(flag.label)) continue
await api.collection('trail_flags').create({ ...flag, team: teamId })
created++
}
return created
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTrailFlagContext() {
const store = new TrailFlagStore()
setContext(KEY, store)
return store
}
export function getTrailFlagContext(): TrailFlagStore {
return getContext<TrailFlagStore>(KEY)
}
```
- [ ] **Step 2: `trailMarkers.svelte.ts` schreiben**
```ts
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import type { TrailMarkersResponse } from '$lib/types'
const KEY = Symbol('trailMarker')
export class TrailMarkerStore {
records = $state<TrailMarkersResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
/** Alle Marker eines Trails, jüngste zuerst */
byTrail(trailId: string): TrailMarkersResponse[] {
return this.records
.filter((r) => r.trail === trailId)
.sort((a, b) => (a.created < b.created ? 1 : -1))
}
/** Nur die offenen — was auf der Karte auffallen soll */
openByTrail(trailId: string): TrailMarkersResponse[] {
return this.byTrail(trailId).filter((r) => !r.resolved)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('trail_markers').getFullList({
sort: '-created',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Marker'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('trail_markers').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records]
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('trail_markers subscribe failed:', e))
}
async create(data: { trail: string; flag: string; lat: number; lng: number; note?: string }) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
return await api.collection('trail_markers').create({
...data,
team: teamId,
resolved: false,
created_by: auth.user?.id,
})
}
async edit(id: string, data: Partial<TrailMarkersResponse>) {
return await api.collection('trail_markers').update(id, data)
}
/** Erledigt statt gelöscht — die Meldung bleibt als Historie erhalten. */
async toggleResolved(id: string) {
const marker = this.records.find((r) => r.id === id)
if (!marker) throw new Error('Marker nicht gefunden')
await api.collection('trail_markers').update(id, { resolved: !marker.resolved })
}
async remove(id: string) {
await api.collection('trail_markers').delete(id)
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTrailMarkerContext() {
const store = new TrailMarkerStore()
setContext(KEY, store)
return store
}
export function getTrailMarkerContext(): TrailMarkerStore {
return getContext<TrailMarkerStore>(KEY)
}
```
- [ ] **Step 3: Typprüfung**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5
```
Erwartet: 0 Fehler, 0 Warnungen.
- [ ] **Step 4: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/lib/stores/trailFlags.svelte.ts frontend/src/lib/stores/trailMarkers.svelte.ts
git commit -m "feat: Stores für Flag-Typen und Trail-Marker"
```
---
### Task 5: Stores registrieren und Navigation ergänzen
**Files:**
- Modify: `frontend/src/routes/dashboard/+layout.svelte`
**Interfaces:**
- Consumes: die vier `setXContext()`-Funktionen aus Task 3 und 4
- Produces: Die Stores sind in allen Dashboard-Routen über `getTrailContext()` usw. erreichbar; die Navigation enthält einen Eintrag „Trails".
- [ ] **Step 1: Imports ergänzen**
In `frontend/src/routes/dashboard/+layout.svelte` bei den übrigen Store-Imports:
```ts
import { setTrailContext } from '$lib/stores/trails.svelte'
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
```
Beim Icon-Import aus `lucide-svelte` zusätzlich `Route` aufnehmen (für den Navigationseintrag).
- [ ] **Step 2: Stores anlegen**
Nach `const times = setTimeContext()` ergänzen. Die Reihenfolge ist wichtig:
`setTrailContext()` ruft intern `getTeamContext()` auf, `teams` muss also
vorher existieren — das ist bereits der Fall.
```ts
const trails = setTrailContext()
const trailVersions = setTrailVersionContext()
const trailFlags = setTrailFlagContext()
const trailMarkers = setTrailMarkerContext()
```
- [ ] **Step 3: Laden und Subscriben ergänzen**
Die bestehende Zeile
```ts
await Promise.all([events.load(), runs.load(), riders.load(), times.load()])
```
ersetzen durch
```ts
await Promise.all([
events.load(), runs.load(), riders.load(), times.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
])
```
und nach `times.subscribe()` ergänzen:
```ts
trails.subscribe()
trailVersions.subscribe()
trailFlags.subscribe()
trailMarkers.subscribe()
```
Zur Sicherheit: Die Collections sind verschieden, deshalb greift die
Auto-Cancellation des SDK nicht — sie gruppiert nach Methode und Pfad.
- [ ] **Step 4: Navigationseintrag ergänzen**
Im Array `nav` nach dem Events-Eintrag:
```ts
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
```
- [ ] **Step 5: Prüfen, dass die App noch startet**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -3
```
Erwartet: 0 Fehler, 0 Warnungen.
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/backend && docker compose up -d
cd ../frontend && npm run dev
```
Im Browser `http://stammtisch-hersbruck.de.localhost:31337/dashboard` öffnen,
anmelden. Erwartet: Die Seite lädt, in der Navigation steht „Trails", und die
Konsole zeigt keine Fehler zu `trail_*`-Collections. Ein Klick auf „Trails"
führt zu einem 404 — die Route entsteht erst in Task 7.
Danach den Dev-Server mit `Ctrl+C` beenden.
- [ ] **Step 6: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/routes/dashboard/+layout.svelte
git commit -m "feat: Trail-Stores registrieren und Navigationseintrag ergänzen"
```
---
### Task 6: Kartenkomponente und Höhenprofil
**Files:**
- Create: `frontend/src/lib/components/TrailMap.svelte`
- Create: `frontend/src/lib/components/ElevationProfile.svelte`
- Modify: `frontend/package.json` (maplibre-gl)
**Interfaces:**
- Consumes: nichts aus den Stores — beide Komponenten bekommen alles über Props
- Produces:
```svelte
<!-- TrailMap.svelte -->
Props: {
geojson?: { type: 'LineString'; coordinates: [number, number][] } | null
bounds?: [[number, number], [number, number]] | null
markers?: { id: string; lat: number; lng: number; color: string; resolved: boolean; label: string }[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
placing?: boolean // Setzmodus: nächster Klick meldet Koordinaten
highlightIndex?: number | null // Punkt aus dem Höhenprofil hervorheben
onplace?: (coords: { lat: number; lng: number }) => void
onmarkerclick?: (id: string) => void
height?: string // CSS-Höhe, Default '420px'
}
<!-- ElevationProfile.svelte -->
Props: {
elevation: { d: number; ele: number }[]
onhover?: (index: number | null) => void
height?: string // Default '120px'
}
```
- [ ] **Step 1: MapLibre installieren**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm install maplibre-gl
```
- [ ] **Step 2: `TrailMap.svelte` schreiben**
```svelte
<script lang="ts">
/**
* Karte für einen Trail. Bewusst ohne Store-Zugriff: Alles kommt über
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
*/
import { onMount } from 'svelte'
// maplibre-gl 6.x hat keinen Default-Export — ein `import maplibregl from`
// scheitert mit TS1192.
import * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
type MarkerData = {
id: string
lat: number
lng: number
color: string
resolved: boolean
label: string
}
let {
geojson = null,
bounds = null,
markers = [],
status = 'offen',
placing = false,
highlightIndex = null,
onplace,
onmarkerclick,
height = '420px',
}: {
geojson?: LineGeoJSON | null
bounds?: [[number, number], [number, number]] | null
markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
placing?: boolean
highlightIndex?: number | null
onplace?: (coords: { lat: number; lng: number }) => void
onmarkerclick?: (id: string) => void
height?: string
} = $props()
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine
// Outdoor-Karte mit Höhenlinien tauschen lässt.
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende'
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
const LINE_COLORS = {
offen: '#16a34a',
eingeschraenkt: '#ca8a04',
gesperrt: '#dc2626',
} as const
let container: HTMLDivElement
let map = $state<MapLibreMap | null>(null)
let ready = $state(false)
let markerObjects: Marker[] = []
let highlightMarker: Marker | null = null
onMount(() => {
map = new maplibregl.Map({
container,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: [TILES],
tileSize: 256,
attribution: ATTRIBUTION,
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
},
center: FALLBACK_CENTER,
zoom: 11,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(new maplibregl.ScaleControl(), 'bottom-left')
map.on('load', () => {
ready = true
})
map.on('click', (e) => {
if (placing) onplace?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
})
return () => {
map?.remove()
map = null
}
})
// Trail-Linie zeichnen bzw. aktualisieren
$effect(() => {
if (!map || !ready) return
const data = geojson
const color = LINE_COLORS[status] ?? LINE_COLORS.offen
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) {
if (src) {
src.setData({ type: 'FeatureCollection', features: [] })
}
return
}
const feature = { type: 'Feature' as const, properties: {}, geometry: data }
if (src) {
src.setData(feature)
map.setPaintProperty('trail-line', 'line-color', color)
} else {
map.addSource('trail', { type: 'geojson', data: feature })
map.addLayer({
id: 'trail-line',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 },
})
}
})
// Kartenausschnitt auf den Track setzen
$effect(() => {
if (!map || !ready || !bounds) return
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
})
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und
// zuverlässiger als einzelne Aktualisierungen.
$effect(() => {
if (!map || !ready) return
for (const m of markerObjects) m.remove()
markerObjects = []
for (const data of markers) {
const el = document.createElement('button')
el.type = 'button'
el.title = data.label
el.setAttribute('aria-label', data.label)
el.style.cssText = `
width: 18px; height: 18px; border-radius: 9999px;
border: 2px solid white; cursor: pointer; padding: 0;
background: ${data.color};
opacity: ${data.resolved ? '0.35' : '1'};
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onmarkerclick?.(data.id)
})
markerObjects.push(
new maplibregl.Marker({ element: el })
.setLngLat([data.lng, data.lat])
.addTo(map),
)
}
})
// Stelle aus dem Höhenprofil hervorheben
$effect(() => {
if (!map || !ready) return
highlightMarker?.remove()
highlightMarker = null
const coords = geojson?.coordinates
if (highlightIndex == null || !coords?.length) return
const idx = Math.max(0, Math.min(coords.length - 1, highlightIndex))
const el = document.createElement('div')
el.style.cssText = `
width: 12px; height: 12px; border-radius: 9999px;
background: #0ea5e9; border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
highlightMarker = new maplibregl.Marker({ element: el })
.setLngLat(coords[idx])
.addTo(map)
})
</script>
<div
bind:this={container}
style:height
class="w-full rounded-lg overflow-hidden border"
class:cursor-crosshair={placing}
></div>
{#if placing}
<p class="text-sm text-muted-foreground mt-2">
Klicke auf die Karte, um die Stelle zu markieren.
</p>
{/if}
```
- [ ] **Step 3: `ElevationProfile.svelte` schreiben**
Kein Diagramm-Paket — die Daten sind eine Zahlenreihe, ein SVG-Pfad genügt.
```svelte
<script lang="ts">
/**
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente den
* Index des Punktes; die Elternkomponente reicht ihn an die Karte weiter,
* damit dort dieselbe Stelle hervorgehoben wird.
*/
let {
elevation,
onhover,
height = '120px',
}: {
elevation: { d: number; ele: number }[]
onhover?: (index: number | null) => void
height?: string
} = $props()
// Internes Koordinatensystem; das SVG skaliert über viewBox mit.
const W = 1000
const H = 200
const stats = $derived.by(() => {
if (elevation.length < 2) return null
const eles = elevation.map((p) => p.ele)
const min = Math.min(...eles)
const max = Math.max(...eles)
const totalD = elevation[elevation.length - 1].d || 1
// Bei flachen Strecken wäre die Spanne 0 und die Division ungültig.
const span = max - min || 1
return { min, max, totalD, span }
})
const path = $derived.by(() => {
if (!stats) return ''
const { min, totalD, span } = stats
const pts = elevation.map((p) => {
const x = (p.d / totalD) * W
const y = H - ((p.ele - min) / span) * H
return `${x.toFixed(1)},${y.toFixed(1)}`
})
// Fläche schließen: runter zur Grundlinie und zurück zum Anfang
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
})
let hoverX = $state<number | null>(null)
function handleMove(e: MouseEvent) {
if (!elevation.length) return
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const idx = Math.round(ratio * (elevation.length - 1))
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
hoverX = ratio * W
onhover?.(clamped)
}
function handleLeave() {
hoverX = null
onhover?.(null)
}
</script>
{#if stats}
<div class="w-full" style:height>
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="w-full h-full"
role="img"
aria-label="Höhenprofil"
onmousemove={handleMove}
onmouseleave={handleLeave}
>
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
{#if hoverX !== null}
<line x1={hoverX} y1="0" x2={hoverX} y2={H} stroke="rgb(14 165 233)" stroke-width="1" vector-effect="non-scaling-stroke" />
{/if}
</svg>
</div>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{Math.round(stats.min)} m</span>
<span>{(stats.totalD / 1000).toFixed(1)} km</span>
<span>{Math.round(stats.max)} m</span>
</div>
{:else}
<p class="text-sm text-muted-foreground">Keine Höhendaten vorhanden.</p>
{/if}
```
- [ ] **Step 4: Typprüfung**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5
```
Erwartet: 0 Fehler, 0 Warnungen.
- [ ] **Step 5: Svelte-Code validieren**
Nutze den Svelte-MCP-Server (`mcp__svelte__svelte-autofixer`) für beide neuen
Komponenten. Er prüft, ob die Runes-Syntax korrekt ist. Gemeldete Probleme
beheben und erneut prüfen, bis nichts mehr offen ist.
- [ ] **Step 6: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/lib/components/TrailMap.svelte frontend/src/lib/components/ElevationProfile.svelte frontend/package.json frontend/package-lock.json
git commit -m "feat: Kartenkomponente mit MapLibre und Höhenprofil"
```
---
### Task 7: Trail-Liste
**Files:**
- Create: `frontend/src/routes/dashboard/trails/+page.svelte`
**Interfaces:**
- Consumes: `getTrailContext()`, `getTrailVersionContext()`, `getTrailMarkerContext()`, `TrailMap.svelte`
- Produces: Route `/dashboard/trails`
- [ ] **Step 1: Seite schreiben**
```svelte
<script lang="ts">
import { goto } from '$app/navigation'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import { Plus, Route, AlertTriangle } from 'lucide-svelte'
const trails = getTrailContext()
const versions = getTrailVersionContext()
const markers = getTrailMarkerContext()
let dialog = $state(false)
let form = $state({ name: '', description: '' })
let saving = $state(false)
let error = $state<string | null>(null)
const STATUS_LABEL = {
offen: 'Offen',
eingeschraenkt: 'Eingeschränkt',
gesperrt: 'Gesperrt',
} as const
const STATUS_VARIANT = {
offen: 'default',
eingeschraenkt: 'secondary',
gesperrt: 'destructive',
} as const
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
function currentVersion(trailId: string) {
const trail = trails.getById(trailId)
if (!trail?.current) return null
return versions.records.find((v) => v.id === trail.current) ?? null
}
function formatKm(m: number | undefined) {
if (!m) return ''
return `${(m / 1000).toFixed(1)} km`
}
async function save() {
if (!form.name.trim()) {
error = 'Bitte einen Namen angeben.'
return
}
saving = true
error = null
try {
const rec = await trails.create({
name: form.name.trim(),
description: form.description,
})
dialog = false
form = { name: '', description: '' }
await goto(`/dashboard/trails/${rec.id}`)
} catch (e: any) {
error = e.message ?? 'Der Trail konnte nicht angelegt werden.'
} finally {
saving = false
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Trails</h1>
<p class="text-muted-foreground text-sm">
{trails.scoped.length}
{trails.scoped.length === 1 ? 'Trail' : 'Trails'} im Team
</p>
</div>
<Button onclick={() => (dialog = true)}>
<Plus class="size-4 mr-2" />
Neuer Trail
</Button>
</div>
{#if trails.loading}
<p class="text-muted-foreground">Trails werden geladen …</p>
{:else if trails.scoped.length === 0}
<Card>
<CardContent class="py-10 text-center space-y-3">
<Route class="size-10 mx-auto text-muted-foreground" />
<p class="text-muted-foreground">
Noch kein Trail angelegt. Leg einen an und lade eine GPX-Datei hoch.
</p>
<Button onclick={() => (dialog = true)}>
<Plus class="size-4 mr-2" />
Neuer Trail
</Button>
</CardContent>
</Card>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each trails.scoped as trail (trail.id)}
{@const version = currentVersion(trail.id)}
{@const open = markers.openByTrail(trail.id).length}
<a href="/dashboard/trails/{trail.id}" class="block">
<Card class="h-full hover:border-primary transition-colors">
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-2">
<CardTitle class="text-base">{trail.name}</CardTitle>
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
{STATUS_LABEL[trail.status] ?? trail.status}
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-3">
{#if version?.geojson}
<TrailMap
geojson={version.geojson}
bounds={version.bounds}
status={trail.status}
height="140px"
/>
{:else}
<div class="h-[140px] rounded-lg border border-dashed grid place-items-center text-sm text-muted-foreground">
Noch keine GPX-Datei
</div>
{/if}
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatKm(version?.distance_m)}</span>
<span>{version?.ascent_m ? `${version.ascent_m} hm` : ''}</span>
{#if open > 0}
<span class="flex items-center gap-1 text-amber-600 ml-auto">
<AlertTriangle class="size-4" />
{open}
</span>
{/if}
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
{/if}
</div>
<Dialog.Root bind:open={dialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neuer Trail</Dialog.Title>
<Dialog.Description>
Die GPX-Datei lädst du im nächsten Schritt hoch.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="trail-name">Name</Label>
<Input id="trail-name" bind:value={form.name} placeholder="z. B. Steinberg-Abfahrt" />
</div>
<div class="space-y-2">
<Label for="trail-desc">Beschreibung</Label>
<Input id="trail-desc" bind:value={form.description} placeholder="optional" />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (dialog = false)}>Abbrechen</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Wird angelegt …' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
```
- [ ] **Step 2: Typprüfung und Svelte-Validierung**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5
```
Erwartet: 0 Fehler, 0 Warnungen. Zusätzlich die Datei durch
`mcp__svelte__svelte-autofixer` prüfen.
- [ ] **Step 3: Im Browser prüfen**
Backend starten, Dev-Server starten, anmelden, `/dashboard/trails` öffnen.
Erwartet: Leerzustand mit dem Hinweis „Noch kein Trail angelegt". Über „Neuer
Trail" einen anlegen — die App springt danach auf die Detailseite, die noch
404 liefert (entsteht in Task 8). Zurück auf `/dashboard/trails`: Der Trail
erscheint als Karte mit dem Hinweis „Noch keine GPX-Datei".
- [ ] **Step 4: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/routes/dashboard/trails/+page.svelte
git commit -m "feat: Trail-Liste mit Vorschaukarten"
```
---
### Task 8: Trail-Detailseite
**Files:**
- Create: `frontend/src/routes/dashboard/trails/[id]/+page.svelte`
**Interfaces:**
- Consumes: alle vier Trail-Stores, `TrailMap.svelte`, `ElevationProfile.svelte`, `getFileURL` aus `$lib/stores/pocketbase.svelte`
- Produces: Route `/dashboard/trails/[id]`
- [ ] **Step 1: Seite schreiben**
```svelte
<script lang="ts">
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { api, auth, getFileURL } from '$lib/stores/pocketbase.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
ArrowLeft, Upload, MapPin, MessageSquare, History,
Check, Trash2, Download, Plus, AlertTriangle,
} from 'lucide-svelte'
import type { TrailCommentsResponse } from '$lib/types'
const trails = getTrailContext()
const versions = getTrailVersionContext()
const flags = getTrailFlagContext()
const markers = getTrailMarkerContext()
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
const canEdit = $derived(trail ? trails.canEdit(trail) : false)
const version = $derived.by(() => {
if (!trail?.current) return null
return versions.records.find((v) => v.id === trail.current) ?? null
})
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
// Für die Karte aufbereitete Marker
const mapMarkers = $derived(
trailMarkers.map((m) => {
const flag = flags.getById(m.flag)
return {
id: m.id,
lat: m.lat,
lng: m.lng,
color: flag?.color ?? '#64748b',
resolved: !!m.resolved,
label: flag?.label ?? 'Marker',
}
}),
)
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
let highlight = $state<number | null>(null)
// --- GPX-Upload ------------------------------------------------------
let uploadDialog = $state(false)
let uploadFile = $state<File | null>(null)
let uploadNote = $state('')
let uploading = $state(false)
let uploadError = $state<string | null>(null)
async function doUpload() {
if (!trail || !uploadFile) return
uploading = true
uploadError = null
try {
await versions.upload(trail.id, uploadFile, uploadNote)
uploadDialog = false
uploadFile = null
uploadNote = ''
} catch (e: any) {
// parseGpx wirft mit verständlichem Text — direkt zeigen
uploadError = e.message ?? 'Die Datei konnte nicht verarbeitet werden.'
} finally {
uploading = false
}
}
// --- Marker setzen ---------------------------------------------------
let placing = $state(false)
let markerDialog = $state(false)
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
let markerFlag = $state('')
let markerNote = $state('')
let markerError = $state<string | null>(null)
function onPlace(coords: { lat: number; lng: number }) {
markerCoords = coords
markerFlag = flags.scoped[0]?.id ?? ''
markerNote = ''
markerError = null
placing = false
markerDialog = true
}
async function saveMarker() {
if (!trail || !markerCoords) return
if (!markerFlag) {
markerError = 'Bitte einen Flag-Typ wählen.'
return
}
try {
await markers.create({
trail: trail.id,
flag: markerFlag,
lat: markerCoords.lat,
lng: markerCoords.lng,
note: markerNote,
})
markerDialog = false
markerCoords = null
} catch (e: any) {
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
}
}
// --- Kommentare ------------------------------------------------------
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
let comments = $state<TrailCommentsResponse[]>([])
let commentText = $state('')
let commentError = $state<string | null>(null)
let unsubComments: (() => void) | null = null
$effect(() => {
const id = trail?.id
if (!id) return
let cancelled = false
api.collection('trail_comments')
.getFullList({ filter: `trail="${id}"`, sort: '-created', requestKey: null })
.then((r) => {
if (!cancelled) comments = r
})
.catch((e) => console.error('Kommentare laden fehlgeschlagen:', e))
api.collection('trail_comments')
.subscribe('*', (e) => {
if (e.record.trail !== id) return
const idx = comments.findIndex((c) => c.id === e.record.id)
if (e.action === 'create' && idx === -1) comments = [e.record, ...comments]
else if (e.action === 'update' && idx !== -1) comments[idx] = e.record
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== e.record.id)
})
.then((u) => {
if (cancelled) u()
else unsubComments = u
})
.catch((e) => console.warn('trail_comments subscribe failed:', e))
return () => {
cancelled = true
unsubComments?.()
unsubComments = null
}
})
async function addComment() {
if (!trail || !commentText.trim()) return
commentError = null
try {
await api.collection('trail_comments').create({
trail: trail.id,
team: trail.team,
text: commentText.trim(),
created_by: auth.user?.id,
})
commentText = ''
} catch (e: any) {
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
}
}
// --- Hilfsfunktionen -------------------------------------------------
const STATUS_LABEL = {
offen: 'Offen',
eingeschraenkt: 'Eingeschränkt',
gesperrt: 'Gesperrt',
} as const
const STATUS_VARIANT = {
offen: 'default',
eingeschraenkt: 'secondary',
gesperrt: 'destructive',
} as const
function formatDate(s: string) {
return new Date(s).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
}
async function setStatus(status: string) {
if (!trail) return
await trails.edit(trail.id, { status } as any)
}
</script>
{#if !trail}
<div class="space-y-4">
<Button variant="ghost" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Zurück
</Button>
<p class="text-muted-foreground">Trail nicht gefunden.</p>
</div>
{:else}
<div class="space-y-6">
<!-- Kopf -->
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Trails
</Button>
<h1 class="text-2xl font-semibold">{trail.name}</h1>
{#if safeDescription}
<!-- description ist ein editor-Feld und enthält HTML.
Ungefiltert wäre es ein Weg, Skriptcode bei allen
Team-Mitgliedern auszuführen — deshalb DOMPurify. -->
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if}
</div>
<div class="flex items-center gap-2">
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
{STATUS_LABEL[trail.status] ?? trail.status}
</Badge>
{#if canEdit}
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
</div>
{#if canEdit}
<div class="flex gap-2">
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
<Button
variant={trail.status === s ? 'default' : 'outline'}
size="sm"
onclick={() => setStatus(s)}
>
{STATUS_LABEL[s as keyof typeof STATUS_LABEL]}
</Button>
{/each}
</div>
{/if}
<!-- Karte -->
<Card>
<CardContent class="pt-6 space-y-4">
{#if version?.geojson}
<TrailMap
geojson={version.geojson}
bounds={version.bounds}
markers={mapMarkers}
status={trail.status}
{placing}
highlightIndex={highlight}
onplace={onPlace}
/>
<div class="flex flex-wrap items-center gap-6 text-sm">
<span><strong>{(version.distance_m / 1000).toFixed(1)}</strong> km</span>
<span><strong>{version.ascent_m}</strong> hm</span>
<Button
variant={placing ? 'default' : 'outline'}
size="sm"
class="ml-auto"
onclick={() => (placing = !placing)}
disabled={flags.scoped.length === 0}
>
<MapPin class="size-4 mr-2" />
{placing ? 'Abbrechen' : 'Marker setzen'}
</Button>
</div>
{#if flags.scoped.length === 0}
<p class="text-sm text-muted-foreground">
Es gibt noch keine Flag-Typen.
<a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a>
</p>
{/if}
{#if version.elevation?.length}
<Separator />
<ElevationProfile
elevation={version.elevation}
onhover={(i) => (highlight = i)}
/>
{/if}
{:else}
<div class="py-12 text-center space-y-3">
<Upload class="size-10 mx-auto text-muted-foreground" />
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
{#if canEdit}
<Button onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
<!-- Reiter -->
<div class="flex gap-2 border-b">
<button
class="px-4 py-2 text-sm border-b-2 -mb-px"
class:border-primary={tab === 'marker'}
class:border-transparent={tab !== 'marker'}
onclick={() => (tab = 'marker')}
>
<MapPin class="size-4 inline mr-1" />
Marker ({trailMarkers.length})
</button>
<button
class="px-4 py-2 text-sm border-b-2 -mb-px"
class:border-primary={tab === 'kommentare'}
class:border-transparent={tab !== 'kommentare'}
onclick={() => (tab = 'kommentare')}
>
<MessageSquare class="size-4 inline mr-1" />
Kommentare ({comments.length})
</button>
<button
class="px-4 py-2 text-sm border-b-2 -mb-px"
class:border-primary={tab === 'versionen'}
class:border-transparent={tab !== 'versionen'}
onclick={() => (tab = 'versionen')}
>
<History class="size-4 inline mr-1" />
Versionen ({trailVersions.length})
</button>
</div>
{#if tab === 'marker'}
{#if trailMarkers.length === 0}
<p class="text-muted-foreground text-sm">
Noch keine Marker. Setze einen über „Marker setzen" auf der Karte.
</p>
{:else}
<div class="space-y-2">
{#each trailMarkers as m (m.id)}
{@const flag = flags.getById(m.flag)}
<Card class={m.resolved ? 'opacity-60' : ''}>
<CardContent class="py-3 flex items-center gap-3">
<span
class="size-3 rounded-full shrink-0"
style:background={flag?.color ?? '#64748b'}
></span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{flag?.label ?? 'Unbekannt'}</p>
{#if m.note}
<p class="text-sm text-muted-foreground">{m.note}</p>
{/if}
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
</div>
<Button
variant="ghost"
size="sm"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={() => markers.toggleResolved(m.id)}
>
<Check class="size-4" />
</Button>
</CardContent>
</Card>
{/each}
</div>
{/if}
{:else if tab === 'kommentare'}
<div class="space-y-4">
<div class="flex gap-2">
<Input
bind:value={commentText}
placeholder="Kommentar schreiben …"
onkeydown={(e) => e.key === 'Enter' && addComment()}
/>
<Button onclick={addComment} disabled={!commentText.trim()}>
<Plus class="size-4" />
</Button>
</div>
{#if commentError}
<p class="text-sm text-destructive">{commentError}</p>
{/if}
{#if comments.length === 0}
<p class="text-muted-foreground text-sm">Noch keine Kommentare.</p>
{:else}
<div class="space-y-2">
{#each comments as c (c.id)}
<Card>
<CardContent class="py-3">
<p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</CardContent>
</Card>
{/each}
</div>
{/if}
</div>
{:else}
{#if trailVersions.length === 0}
<p class="text-muted-foreground text-sm">Noch keine Version hochgeladen.</p>
{:else}
<div class="space-y-2">
{#each trailVersions as v (v.id)}
<Card class={v.id === trail.current ? 'border-primary' : ''}>
<CardContent class="py-3 flex items-center gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{formatDate(v.created)}</p>
{#if v.id === trail.current}
<Badge>Aktiv</Badge>
{/if}
</div>
<p class="text-sm text-muted-foreground">
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
{#if v.note}· {v.note}{/if}
</p>
</div>
{#if v.gpx}
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
<Download class="size-4" />
</Button>
{/if}
{#if canEdit && v.id !== trail.current}
<Button
variant="outline"
size="sm"
onclick={() => versions.activate(trail.id, v.id)}
>
Aktivieren
</Button>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
<!-- GPX-Upload -->
<Dialog.Root bind:open={uploadDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>GPX-Datei hochladen</Dialog.Title>
<Dialog.Description>
Die bisherige Version bleibt erhalten; die neue wird zur aktiven.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="gpx-file">Datei</Label>
<input
id="gpx-file"
type="file"
accept=".gpx,application/gpx+xml,application/xml,text/xml"
class="block w-full text-sm"
onchange={(e) => (uploadFile = e.currentTarget.files?.[0] ?? null)}
/>
</div>
<div class="space-y-2">
<Label for="gpx-note">Notiz</Label>
<Input id="gpx-note" bind:value={uploadNote} placeholder="z. B. Umleitung am Steinbruch" />
</div>
{#if uploadError}
<p class="text-sm text-destructive">{uploadError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (uploadDialog = false)}>Abbrechen</Button>
<Button onclick={doUpload} disabled={!uploadFile || uploading}>
{uploading ? 'Wird verarbeitet …' : 'Hochladen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Marker anlegen -->
<Dialog.Root bind:open={markerDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Marker setzen</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label>Typ</Label>
<div class="flex flex-wrap gap-2">
{#each flags.scoped as f (f.id)}
<Button
variant={markerFlag === f.id ? 'default' : 'outline'}
size="sm"
onclick={() => (markerFlag = f.id)}
>
<span class="size-2 rounded-full mr-2" style:background={f.color}></span>
{f.label}
</Button>
{/each}
</div>
</div>
<div class="space-y-2">
<Label for="marker-note">Notiz</Label>
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
</div>
{#if markerError}
<p class="text-sm text-destructive">{markerError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (markerDialog = false)}>Abbrechen</Button>
<Button onclick={saveMarker}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
```
- [ ] **Step 2: Typprüfung und Svelte-Validierung**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5
```
Erwartet: 0 Fehler, 0 Warnungen. Zusätzlich die Datei durch
`mcp__svelte__svelte-autofixer` prüfen.
- [ ] **Step 3: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/routes/dashboard/trails/
git commit -m "feat: Trail-Detailseite mit Karte, Höhenprofil, Markern und Kommentaren"
```
---
### Task 9: Flag-Verwaltung
**Files:**
- Create: `frontend/src/routes/dashboard/settings/flags/+page.svelte`
**Interfaces:**
- Consumes: `getTrailFlagContext()`, `DEFAULT_FLAGS`
- Produces: Route `/dashboard/settings/flags`
- [ ] **Step 1: Seite schreiben**
```svelte
<script lang="ts">
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
import type { TrailFlagsResponse } from '$lib/types'
const flags = getTrailFlagContext()
let dialog = $state(false)
let editing = $state<TrailFlagsResponse | null>(null)
let form = $state({ label: '', icon: 'Info', color: '#dc2626', severity: 'warnung' })
let error = $state<string | null>(null)
let seeding = $state(false)
const SEVERITIES = [
{ value: 'info', label: 'Info' },
{ value: 'warnung', label: 'Warnung' },
{ value: 'kritisch', label: 'Kritisch' },
]
function openCreate() {
editing = null
form = { label: '', icon: 'Info', color: '#dc2626', severity: 'warnung' }
error = null
dialog = true
}
function openEdit(flag: TrailFlagsResponse) {
editing = flag
form = {
label: flag.label,
icon: flag.icon ?? 'Info',
color: flag.color ?? '#dc2626',
severity: flag.severity ?? 'warnung',
}
error = null
dialog = true
}
async function save() {
if (!form.label.trim()) {
error = 'Bitte eine Bezeichnung angeben.'
return
}
try {
if (editing) await flags.edit(editing.id, { ...form })
else await flags.create({ ...form })
dialog = false
} catch (e: any) {
error = e.message ?? 'Der Flag-Typ konnte nicht gespeichert werden.'
}
}
async function remove(flag: TrailFlagsResponse) {
if (!confirm(`„${flag.label}" wirklich löschen?`)) return
try {
await flags.remove(flag.id)
} catch (e: any) {
// Marker verweisen auf den Flag-Typ — PocketBase lehnt das Löschen ab
alert(e.message ?? 'Der Flag-Typ konnte nicht gelöscht werden. Vermutlich ist er noch in Verwendung.')
}
}
async function seed() {
seeding = true
try {
const n = await flags.seedDefaults()
if (n === 0) alert('Alle Standard-Typen sind bereits vorhanden.')
} catch (e: any) {
alert(e.message ?? 'Die Standard-Typen konnten nicht angelegt werden.')
} finally {
seeding = false
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Flag-Typen</h1>
<p class="text-muted-foreground text-sm">
Wofür sich Marker auf einem Trail setzen lassen.
</p>
</div>
<div class="flex gap-2">
{#if flags.scoped.length === 0}
<Button variant="outline" onclick={seed} disabled={seeding}>
<Sparkles class="size-4 mr-2" />
{seeding ? 'Wird angelegt …' : 'Standardsatz anlegen'}
</Button>
{/if}
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Neuer Typ
</Button>
</div>
</div>
{#if flags.scoped.length === 0}
<Card>
<CardContent class="py-10 text-center text-muted-foreground">
Noch keine Flag-Typen. Leg einen an oder starte mit dem Standardsatz.
</CardContent>
</Card>
{:else}
<div class="space-y-2">
{#each flags.scoped as flag (flag.id)}
<Card>
<CardContent class="py-3 flex items-center gap-3">
<span class="size-4 rounded-full shrink-0" style:background={flag.color}></span>
<span class="font-medium flex-1">{flag.label}</span>
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
{flag.severity}
</Badge>
<Button variant="ghost" size="sm" onclick={() => openEdit(flag)}>
<Pencil class="size-4" />
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(flag)}>
<Trash2 class="size-4" />
</Button>
</CardContent>
</Card>
{/each}
</div>
{/if}
</div>
<Dialog.Root bind:open={dialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Flag-Typ bearbeiten' : 'Neuer Flag-Typ'}</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="flag-label">Bezeichnung</Label>
<Input id="flag-label" bind:value={form.label} placeholder="z. B. Baum quer" />
</div>
<div class="space-y-2">
<Label for="flag-color">Farbe</Label>
<div class="flex gap-2 items-center">
<input id="flag-color" type="color" bind:value={form.color} class="h-9 w-16 rounded border" />
<Input bind:value={form.color} class="flex-1" />
</div>
</div>
<div class="space-y-2">
<Label>Schweregrad</Label>
<div class="flex gap-2">
{#each SEVERITIES as s}
<Button
variant={form.severity === s.value ? 'default' : 'outline'}
size="sm"
onclick={() => (form.severity = s.value)}
>
{s.label}
</Button>
{/each}
</div>
</div>
<div class="space-y-2">
<Label for="flag-icon">Icon</Label>
<Input id="flag-icon" bind:value={form.icon} placeholder="Lucide-Name, z. B. TreePine" />
<p class="text-xs text-muted-foreground">
Name eines Icons von lucide.dev, in Schreibweise wie „TreePine".
</p>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (dialog = false)}>Abbrechen</Button>
<Button onclick={save}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
```
- [ ] **Step 2: Typprüfung und Svelte-Validierung**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5
```
Erwartet: 0 Fehler, 0 Warnungen. Zusätzlich durch `mcp__svelte__svelte-autofixer` prüfen.
- [ ] **Step 3: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add frontend/src/routes/dashboard/settings/
git commit -m "feat: Verwaltung der Flag-Typen"
```
---
### Task 10: Durchgehender Test im Browser
**Files:**
- Keine Änderungen — reine Verifikation. Gefundene Fehler werden behoben und einzeln committet.
**Interfaces:**
- Consumes: alle vorherigen Tasks
- Produces: Nachweis, dass das Feature vollständig funktioniert
- [ ] **Step 1: Eine echte GPX-Testdatei besorgen**
Falls keine zur Hand ist, eine erzeugen:
```bash
cat > /tmp/testtrail.gpx <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Hersbrucker Testrunde</name><trkseg>
<trkpt lat="49.5089" lon="11.4318"><ele>340</ele></trkpt>
<trkpt lat="49.5105" lon="11.4350"><ele>355</ele></trkpt>
<trkpt lat="49.5130" lon="11.4390"><ele>390</ele></trkpt>
<trkpt lat="49.5160" lon="11.4420"><ele>420</ele></trkpt>
<trkpt lat="49.5180" lon="11.4470"><ele>405</ele></trkpt>
<trkpt lat="49.5165" lon="11.4520"><ele>370</ele></trkpt>
<trkpt lat="49.5130" lon="11.4540"><ele>350</ele></trkpt>
<trkpt lat="49.5100" lon="11.4500"><ele>342</ele></trkpt>
<trkpt lat="49.5089" lon="11.4430"><ele>338</ele></trkpt>
</trkseg></trk>
</gpx>
EOF
echo "Testdatei unter /tmp/testtrail.gpx"
```
- [ ] **Step 2: Umgebung starten**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/backend && docker compose up -d
until curl -s http://127.0.0.1:8090/api/health >/dev/null 2>&1; do sleep 1; done
cd ../frontend && npm run dev
```
`frontend/.env` muss dabei auf `PUBLIC_PB_URL=http://127.0.0.1:8090` zeigen.
Nach dem Ändern der Datei den Dev-Server neu starten — die Variable wird zur
Buildzeit eingesetzt.
- [ ] **Step 3: Die Durchstich-Prüfung**
Im Browser anmelden und der Reihe nach:
1. `/dashboard/settings/flags` → „Standardsatz anlegen" → sechs Typen erscheinen
2. `/dashboard/trails` → „Neuer Trail" → Name eingeben → landet auf der Detailseite
3. „GPX hochladen" → `/tmp/testtrail.gpx` wählen → Track erscheint auf der Karte, Kartenausschnitt passt
4. Kennzahlen prüfen: rund 34 km, etwa 110 Höhenmeter
5. Höhenprofil erscheint; beim Überfahren wandert ein blauer Punkt über den Track
6. „Marker setzen" → auf die Karte klicken → Typ wählen → speichern → Punkt erscheint
7. Marker im Reiter „Marker" als erledigt markieren → wird blass
8. Kommentar schreiben → erscheint sofort in der Liste
9. Zweite GPX-Version hochladen (dieselbe Datei genügt) → Reiter „Versionen" zeigt zwei, die neue ist aktiv; **Marker und Kommentar sind noch da**
10. Auf die erste Version „Aktivieren" → Karte wechselt zurück
11. Status auf „Gesperrt" setzen → die Linie wird rot, in der Liste erscheint das Badge
12. `/dashboard/trails` → Vorschaukarte zeigt den Track, offene Marker werden gezählt
- [ ] **Step 4: Realtime prüfen**
Ein zweites Browserfenster auf derselben Trail-Detailseite öffnen. Im ersten
Fenster einen Kommentar schreiben.
Erwartet: Der Kommentar erscheint im zweiten Fenster ohne Neuladen.
- [ ] **Step 5: Rechte prüfen**
Dafür braucht es einen zweiten Nutzer im selben Team, der **nicht** Pate des
Trails und **nicht** Team-Admin ist. Anlegen im PocketBase-Admin unter
`http://127.0.0.1:8090/_/` (Collection `users`), dann im Team als Mitglied
eintragen.
Mit diesem Nutzer anmelden und die Trail-Detailseite öffnen.
Erwartet:
- „GPX hochladen" und die Status-Knöpfe sind **nicht** sichtbar
- Marker setzen und Kommentieren funktioniert
- Ein direkter Schreibversuch scheitert serverseitig. Das lässt sich am
einfachsten mit `curl` prüfen. Dazu den Auth-Token des zweiten Nutzers
besorgen — in der Browser-Konsole auf der angemeldeten Seite:
```js
JSON.parse(localStorage.getItem('pocketbase_auth')).token
```
Damit dann, mit der ID eines Trails, dessen Pate der Nutzer NICHT ist:
```bash
curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH "http://127.0.0.1:8090/api/collections/trails/records/<TRAIL_ID>" \
-H "Authorization: <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name":"Unbefugt geaendert"}'
```
Erwartet: `403` oder `404`. Kommt `200`, ist die `updateRule` zu weit
gefasst — dann Task 1 nachbessern und die Migration erneut anwenden.
Der Schlüsselname im `localStorage` kann abweichen, falls der AuthStore
anders konfiguriert ist; dann in den DevTools unter *Application →
Local Storage* nachsehen.
- [ ] **Step 6: Fehlerfälle prüfen**
1. Eine beliebige Textdatei mit Endung `.gpx` hochladen → Meldung „Die Datei ist kein gültiges XML." oder „enthält keine Punkte", kein Absturz
2. Eine gültige XML-Datei ohne `<trkpt>` hochladen → Meldung „Die GPX-Datei enthält keine Punkte."
3. Einen Flag-Typ löschen, der von einem Marker verwendet wird → verständliche Meldung, kein stiller Fehlschlag
- [ ] **Step 7: Abschließende Prüfungen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm test 2>&1 | tail -5
npm run check 2>&1 | tail -3
```
Erwartet: Alle Tests grün, 0 Fehler und 0 Warnungen.
- [ ] **Step 8: Aufräumen und Doku ergänzen**
Dev-Server und Container stoppen:
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de/backend && docker compose down
```
In `CLAUDE.md` im Abschnitt „Backend Integration (PocketBase)" die
Collections-Zeile ergänzen:
```markdown
- **Collections**: users, teams, events, runs, riders, times, trails,
trail_versions, trail_flags, trail_markers, trail_comments
```
Und unter „Important Patterns" ergänzen:
```markdown
**Karten**: `TrailMap.svelte` und `ElevationProfile.svelte` sind reine
Präsentationskomponenten ohne Store-Zugriff — Daten kommen über Props,
Ereignisse über Callback-Props zurück. GPX wird im Browser geparst
(`$lib/gpx.ts`), das Backend bleibt ohne Hooks.
```
- [ ] **Step 9: Committen**
```bash
cd /home/dne/Projekte/stammtisch-hersbruck.de
git add CLAUDE.md
git status --short
git commit -m "docs: Trail-Collections und Kartenmuster in CLAUDE.md ergänzen"
```
**Kein `git push`** — der bleibt ausdrücklicher Anweisung vorbehalten.
---
## Abschlussbericht
Nach Task 10 an den Nutzer berichten:
- Welche Schritte der Durchstich-Prüfung erfolgreich waren
- Die gemessenen Kennzahlen der Testdatei (Länge, Höhenmeter) und ob sie plausibel sind
- Ergebnis der Rechteprüfung aus Step 5 — insbesondere, ob der Schreibversuch serverseitig abgelehnt wurde
- Falls ein Punkt nicht geprüft werden konnte (etwa mangels zweitem Nutzer): ausdrücklich als **ungeprüft** benennen, nicht als erledigt
- Dass nicht gepusht wurde