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

111 KiB
Raw Permalink Blame History

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.currenttrail_versions wird in einem zweiten Schritt ergänzt, und es gibt keine Seed-Daten.

Datei backend/pb_migrations/1754500000_created_trails.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
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
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
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
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.

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:

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
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:

    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.

cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm install -D vitest

Dann in package.json bei "scripts" ergänzen (nach "check:watch"):

    "test": "vitest run",
    "test:watch": "vitest",
  • Step 2: Die fehlschlagenden Tests schreiben

Datei frontend/src/lib/gpx.test.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
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:

/**
 * 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
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:

    test: {
        environment: 'jsdom',
    },
  • Step 6: Typprüfung
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -3

Erwartet: 0 Fehler, 0 Warnungen.

  • Step 7: Committen
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:

    // 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.

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
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
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
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5

Erwartet: 0 Fehler, 0 Warnungen.

  • Step 5: Committen
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:

    // 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

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
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
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -5

Erwartet: 0 Fehler, 0 Warnungen.

  • Step 4: Committen
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:

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.

    const trails = setTrailContext()
    const trailVersions = setTrailVersionContext()
    const trailFlags = setTrailFlagContext()
    const trailMarkers = setTrailMarkerContext()
  • Step 3: Laden und Subscriben ergänzen

Die bestehende Zeile

        await Promise.all([events.load(), runs.load(), riders.load(), times.load()])

ersetzen durch

        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:

        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:

        { href: '/dashboard/trails', icon: Route, label: 'Trails' },
  • Step 5: Prüfen, dass die App noch startet
cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm run check 2>&1 | tail -3

Erwartet: 0 Fehler, 0 Warnungen.

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
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:

    <!-- 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

cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend
npm install maplibre-gl
  • Step 2: TrailMap.svelte schreiben
<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.

<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
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
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

<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
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
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

<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
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
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

<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
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
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:

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
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:

    JSON.parse(localStorage.getItem('pocketbase_auth')).token
    

    Damit dann, mit der ID eines Trails, dessen Pate der Nutzer NICHT ist:

    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
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:

cd /home/dne/Projekte/stammtisch-hersbruck.de/backend && docker compose down

In CLAUDE.md im Abschnitt „Backend Integration (PocketBase)" die Collections-Zeile ergänzen:

- **Collections**: users, teams, events, runs, riders, times, trails,
  trail_versions, trail_flags, trail_markers, trail_comments

Und unter „Important Patterns" ergänzen:

**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
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