From 2e8f92b6f6969ce59eeeda2f9c185125d2bd37c8 Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Thu, 6 Aug 2026 16:42:18 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Umsetzungsplan=20f=C3=BCr=20Trails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-08-06-trails.md | 3166 +++++++++++++++++++ 1 file changed, 3166 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-trails.md diff --git a/docs/superpowers/plans/2026-08-06-trails.md b/docs/superpowers/plans/2026-08-06-trails.md new file mode 100644 index 0000000..df28480 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-trails.md @@ -0,0 +1,3166 @@ +# 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/_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 3–5 (Stores) müssen vor den +UI-Tasks 6–9 liegen. + +--- + +### Task 1: Migration für die fünf Collections + +**Files:** +- Create: `backend/pb_migrations/1754500000_created_trails.js` +- Modify: `frontend/src/lib/types.d.ts` (generiert) + +**Interfaces:** +- Consumes: nichts +- Produces: Collections `trails`, `trail_versions`, `trail_flags`, `trail_markers`, `trail_comments`. Typen `TrailsResponse`, `TrailVersionsResponse`, `TrailFlagsResponse`, `TrailMarkersResponse`, `TrailCommentsResponse` sowie `TrailsStatusOptions` und `TrailFlagsSeverityOptions` in `$lib/types`. + +- [ ] **Step 1: Migration schreiben** + +Zwei Besonderheiten: Der Zirkelbezug `trails.current` → `trail_versions` wird +in einem zweiten Schritt ergänzt, und es gibt keine Seed-Daten. + +Datei `backend/pb_migrations/1754500000_created_trails.js`: + +```js +/// + +// Trails: GPX-Tracks mit Versionen, verorteten Markern und Kommentaren. +// +// Reihenfolge ist wichtig: trails.current zeigt auf trail_versions, +// trail_versions.trail zeigt zurück auf trails. Deshalb wird trails zuerst +// OHNE current angelegt, dann trail_versions, und current danach ergänzt. + +migrate((app) => { + const teams = app.findCollectionByNameOrId('teams') + const users = app.findCollectionByNameOrId('users') + + // Für jede Collection gleich: Standardfelder, die PocketBase nicht + // selbst anlegt. + const timestamps = [ + { name: 'created', type: 'autodate', onCreate: true, onUpdate: false }, + { name: 'updated', type: 'autodate', onCreate: true, onUpdate: true }, + ] + + const teamScoped = (name) => ({ + name, + type: 'relation', + required: true, + maxSelect: 1, + collectionId: teams.id, + cascadeDelete: true, + }) + + // --- trails (noch ohne current) -------------------------------------- + const trails = new Collection({ + type: 'base', + name: 'trails', + listRule: 'team.users.id ?= @request.auth.id', + viewRule: 'team.users.id ?= @request.auth.id', + createRule: 'team.users.id ?= @request.auth.id', + // Bearbeiten dürfen Paten und Team-Admins + updateRule: + 'stewards.id ?= @request.auth.id' + + ' || team.owner.id ?= @request.auth.id' + + ' || team.admins.id ?= @request.auth.id', + // Löschen nur Admins — am Trail hängen fremde Marker und Kommentare + deleteRule: + 'team.owner.id ?= @request.auth.id' + + ' || team.admins.id ?= @request.auth.id', + fields: [ + teamScoped('team'), + { name: 'name', type: 'text', required: true, max: 200 }, + { name: 'description', type: 'editor' }, + { + name: 'status', + type: 'select', + maxSelect: 1, + values: ['offen', 'eingeschraenkt', 'gesperrt'], + }, + { + name: 'stewards', + type: 'relation', + maxSelect: 0, + collectionId: users.id, + cascadeDelete: false, + }, + { + name: 'event', + type: 'relation', + maxSelect: 1, + collectionId: app.findCollectionByNameOrId('events').id, + cascadeDelete: false, + }, + { + name: 'created_by', + type: 'relation', + maxSelect: 1, + collectionId: users.id, + cascadeDelete: false, + }, + ...timestamps, + ], + }) + app.save(trails) + + // --- trail_versions --------------------------------------------------- + const versions = new Collection({ + type: 'base', + name: 'trail_versions', + listRule: 'trail.team.users.id ?= @request.auth.id', + viewRule: 'trail.team.users.id ?= @request.auth.id', + createRule: + 'trail.stewards.id ?= @request.auth.id' + + ' || trail.team.owner.id ?= @request.auth.id' + + ' || trail.team.admins.id ?= @request.auth.id', + // Versionen sind unveränderlich — eine Korrektur ist eine neue Version + updateRule: null, + deleteRule: + 'trail.team.owner.id ?= @request.auth.id' + + ' || trail.team.admins.id ?= @request.auth.id', + fields: [ + { + name: 'trail', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: trails.id, + cascadeDelete: true, + }, + { + name: 'gpx', + type: 'file', + maxSelect: 1, + maxSize: 10485760, + mimeTypes: ['application/gpx+xml', 'application/xml', 'text/xml'], + }, + { name: 'geojson', type: 'json', maxSize: 5000000 }, + { name: 'elevation', type: 'json', maxSize: 2000000 }, + { name: 'bounds', type: 'json', maxSize: 1000 }, + { name: 'distance_m', type: 'number' }, + { name: 'ascent_m', type: 'number' }, + { name: 'note', type: 'text', max: 500 }, + { + name: 'uploaded_by', + type: 'relation', + maxSelect: 1, + collectionId: users.id, + cascadeDelete: false, + }, + ...timestamps, + ], + }) + app.save(versions) + + // --- trails.current nachtragen --------------------------------------- + trails.fields.add(new Field({ + name: 'current', + type: 'relation', + maxSelect: 1, + collectionId: versions.id, + cascadeDelete: false, + })) + app.save(trails) + + // --- trail_flags ------------------------------------------------------ + const adminOnly = + 'team.owner.id ?= @request.auth.id' + + ' || team.admins.id ?= @request.auth.id' + + const flags = new Collection({ + type: 'base', + name: 'trail_flags', + listRule: 'team.users.id ?= @request.auth.id', + viewRule: 'team.users.id ?= @request.auth.id', + createRule: adminOnly, + updateRule: adminOnly, + deleteRule: adminOnly, + fields: [ + teamScoped('team'), + { name: 'label', type: 'text', required: true, max: 60 }, + { name: 'icon', type: 'text', max: 40 }, + { name: 'color', type: 'text', max: 9 }, + { + name: 'severity', + type: 'select', + maxSelect: 1, + values: ['info', 'warnung', 'kritisch'], + }, + ...timestamps, + ], + }) + app.save(flags) + + // --- trail_markers ---------------------------------------------------- + // Anlegen darf jedes Team-Mitglied; ändern nur der Ersteller, Paten + // oder Admins. + const ownerOrSteward = + 'created_by.id ?= @request.auth.id' + + ' || trail.stewards.id ?= @request.auth.id' + + ' || team.owner.id ?= @request.auth.id' + + ' || team.admins.id ?= @request.auth.id' + + const markers = new Collection({ + type: 'base', + name: 'trail_markers', + listRule: 'team.users.id ?= @request.auth.id', + viewRule: 'team.users.id ?= @request.auth.id', + createRule: 'team.users.id ?= @request.auth.id', + updateRule: ownerOrSteward, + deleteRule: ownerOrSteward, + fields: [ + { + name: 'trail', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: trails.id, + cascadeDelete: true, + }, + teamScoped('team'), + { + name: 'flag', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: flags.id, + cascadeDelete: false, + }, + { name: 'lat', type: 'number', required: true }, + { name: 'lng', type: 'number', required: true }, + { name: 'note', type: 'text', max: 500 }, + { name: 'resolved', type: 'bool' }, + { + name: 'created_by', + type: 'relation', + maxSelect: 1, + collectionId: users.id, + cascadeDelete: false, + }, + ...timestamps, + ], + }) + app.save(markers) + + // --- trail_comments --------------------------------------------------- + const comments = new Collection({ + type: 'base', + name: 'trail_comments', + listRule: 'team.users.id ?= @request.auth.id', + viewRule: 'team.users.id ?= @request.auth.id', + createRule: 'team.users.id ?= @request.auth.id', + updateRule: ownerOrSteward, + deleteRule: ownerOrSteward, + fields: [ + { + name: 'trail', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: trails.id, + cascadeDelete: true, + }, + teamScoped('team'), + { name: 'text', type: 'text', required: true, max: 2000 }, + { + name: 'created_by', + type: 'relation', + maxSelect: 1, + collectionId: users.id, + cascadeDelete: false, + }, + ...timestamps, + ], + }) + app.save(comments) +}, (app) => { + // Rückwärts in umgekehrter Reihenfolge, damit keine Relation ins Leere + // zeigt. trails zuletzt, weil alle anderen darauf verweisen. + for (const name of [ + 'trail_comments', + 'trail_markers', + 'trail_flags', + 'trail_versions', + 'trails', + ]) { + try { + app.delete(app.findCollectionByNameOrId(name)) + } catch { + // Bereits entfernt — nichts zu tun. + } + } +}) +``` + +- [ ] **Step 2: Syntax prüfen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +node --check backend/pb_migrations/1754500000_created_trails.js && echo "Syntax ok" +``` + +Erwartet: „Syntax ok". (`migrate`, `Collection` und `Field` sind erst zur Laufzeit definiert — das stört `node --check` nicht.) + +- [ ] **Step 3: Migration im Container anwenden** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/backend +docker compose up -d --build +until curl -s http://127.0.0.1:8090/api/health >/dev/null 2>&1; do sleep 1; done +sleep 3 +docker compose logs --no-log-prefix 2>&1 | tail -10 +``` + +Erwartet: Keine Zeile mit `failed to apply migration`. Erscheint eine, ist die Migration fehlerhaft — Fehlermeldung lesen und beheben, nicht die DB löschen. + +- [ ] **Step 4: Collections und Rules prüfen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/backend +set -a && . ./.env && set +a +TOKEN=$(curl -s -X POST http://127.0.0.1:8090/api/collections/_superusers/auth-with-password \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$SUPERUSER_EMAIL\",\"password\":\"$SUPERUSER_PASSWORD\"}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))") + +curl -s -H "Authorization: $TOKEN" "http://127.0.0.1:8090/api/collections?perPage=200" \ + | python3 -c " +import sys, json +d = json.load(sys.stdin) +want = ['trails','trail_versions','trail_flags','trail_markers','trail_comments'] +have = {c['name']: c for c in d['items']} +for n in want: + if n not in have: + print(f' {n}: FEHLT'); continue + c = have[n] + print(f\" {n}: {len(c['fields'])} Felder, update={c.get('updateRule')!r}\") +missing = [n for n in want if n not in have] +print('OK' if not missing else 'FEHLEN: ' + str(missing)) +" +``` + +Erwartet: Alle fünf Collections, letzte Zeile `OK`. Bei `trail_versions` muss `update=None` stehen (Versionen sind unveränderlich). + +- [ ] **Step 5: Zirkelbezug prüfen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/backend +curl -s -H "Authorization: $TOKEN" "http://127.0.0.1:8090/api/collections/trails" \ + | python3 -c " +import sys, json +c = json.load(sys.stdin) +cur = [f for f in c['fields'] if f['name'] == 'current'] +print('current-Feld vorhanden:', bool(cur)) +if cur: print(' zeigt auf collectionId:', cur[0].get('collectionId')) +" +``` + +Erwartet: `current-Feld vorhanden: True` mit einer `collectionId`. Fehlt es, wurde Step „trails.current nachtragen" nicht ausgeführt. + +- [ ] **Step 6: Typen generieren** + +Die Typen kommen aus der Migration im Repo, nicht aus einer laufenden Instanz. + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run generate-pocketbase-types +grep -E "^\t(Trails|TrailVersions|TrailFlags|TrailMarkers|TrailComments) = " src/lib/types.d.ts +``` + +Erwartet: Alle fünf Einträge erscheinen. + +Wichtig: `scripts/schema-to-json.mjs` nimmt die **jüngste** Migration mit einem `const collections = ` — das ist weiterhin die Snapshot-Migration `1754400000_init_schema.js`, nicht die neue. Die neue Migration verwendet `new Collection(...)` statt eines Collections-Arrays und wird vom Script übersprungen. **Deshalb liefert der Aufruf hier noch nicht die neuen Typen.** Statt das Script umzubauen, wird der Snapshot aktualisiert: + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/backend +curl -s -H "Authorization: $TOKEN" "http://127.0.0.1:8090/api/collections?perPage=200" -o /tmp/local.json +cd /home/dne/Projekte/stammtisch-hersbruck.de +python3 - <<'PY' +import json, re + +with open('/tmp/local.json') as f: + items = json.load(f)['items'] + +keep = [c for c in items if not c['name'].startswith('_')] +order = {'users':0,'teams':1,'events':2,'runs':3,'riders':4,'times':5, + 'trails':6,'trail_versions':7,'trail_flags':8,'trail_markers':9, + 'trail_comments':10} +keep.sort(key=lambda c: order.get(c['name'], 99)) + +path = 'backend/pb_migrations/1754400000_init_schema.js' +src = open(path).read() +marker = 'const collections = ' +start = src.index(marker) + len(marker) +depth = 0 +for i in range(start, len(src)): + if src[i] == '[': depth += 1 + elif src[i] == ']': + depth -= 1 + if depth == 0: + end = i + 1 + break + +new = json.dumps(keep, indent=4, ensure_ascii=False) +open(path, 'w').write(src[:start] + new + src[end:]) +print('Snapshot aktualisiert:', [c['name'] for c in keep]) +PY +cd frontend && npm run generate-pocketbase-types +grep -E "^\t(Trails|TrailVersions|TrailFlags|TrailMarkers|TrailComments) = " src/lib/types.d.ts +``` + +Erwartet: Der Snapshot enthält jetzt elf Collections, und alle fünf neuen Typen erscheinen. + +- [ ] **Step 7: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add backend/pb_migrations/ frontend/src/lib/types.d.ts +git status --short +git commit -m "feat: Collections für Trails, Versionen, Flags, Marker und Kommentare" +``` + +--- + +### Task 2: GPX-Parser + +**Files:** +- Create: `frontend/src/lib/gpx.ts` +- Create: `frontend/src/lib/gpx.test.ts` +- Modify: `frontend/package.json` (Test-Runner) + +**Interfaces:** +- Consumes: nichts +- Produces: + ```ts + export type TrackPoint = { lat: number; lng: number; ele: number | null } + export type ParsedGpx = { + points: TrackPoint[] // vereinfacht, für die Anzeige + geojson: { type: 'LineString'; coordinates: [number, number][] } + elevation: { d: number; ele: number }[] // d = Distanz in Metern + bounds: [[number, number], [number, number]] // [[minLng,minLat],[maxLng,maxLat]] + distance_m: number + ascent_m: number + } + export function parseGpx(xml: string): ParsedGpx // wirft bei ungültigem GPX + export function haversine(a: TrackPoint, b: TrackPoint): number + export function simplify(points: TrackPoint[], maxPoints?: number): TrackPoint[] + ``` + +- [ ] **Step 1: Test-Runner einrichten** + +Das Projekt hat noch keinen. Vitest passt zu Vite und braucht keine eigene Konfiguration. + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm install -D vitest +``` + +Dann in `package.json` bei `"scripts"` ergänzen (nach `"check:watch"`): + +```json + "test": "vitest run", + "test:watch": "vitest", +``` + +- [ ] **Step 2: Die fehlschlagenden Tests schreiben** + +Datei `frontend/src/lib/gpx.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { parseGpx, haversine, simplify } from './gpx' + +// Zwei Punkte in Hersbruck, rund 1,4 km auseinander. +const SAMPLE = ` + + Testtrail + 340 + 350 + 345 + 400 + +` + +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('ignoriert Höhenschwankungen unter 3 m', () => { + const noisy = ` + + 300 + 301 + 302 + 303 +` + // Lauter 1-m-Schritte: reines GPS-Rauschen, kein echter Anstieg + 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 = ` + + + +` + 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 = ` + + 300 + 310 +` + expect(parseGpx(route).points).toHaveLength(2) + }) + + it('wirft bei einer Datei ohne Punkte', () => { + const empty = ` +` + expect(() => parseGpx(empty)).toThrow(/keine Punkte/i) + }) + + it('wirft bei ungültigem XML', () => { + expect(() => parseGpx('kein xml')).toThrow(/ung(ü|ue)ltig/i) + }) +}) + +describe('simplify', () => { + it('lässt kurze Punktfolgen unangetastet', () => { + const pts = [ + { lat: 49.51, lng: 11.43, ele: null }, + { lat: 49.52, lng: 11.44, ele: null }, + ] + expect(simplify(pts, 100)).toHaveLength(2) + }) + + it('reduziert lange Punktfolgen auf das Maximum', () => { + const pts = Array.from({ length: 5000 }, (_, i) => ({ + lat: 49.5 + i * 0.0001, + lng: 11.4 + Math.sin(i / 50) * 0.01, + ele: null, + })) + const out = simplify(pts, 500) + expect(out.length).toBeLessThanOrEqual(500) + expect(out.length).toBeGreaterThan(10) + }) + + it('behält Anfang und Ende bei', () => { + const pts = Array.from({ length: 1000 }, (_, i) => ({ + lat: 49.5 + i * 0.001, + lng: 11.4 + i * 0.001, + ele: null, + })) + const out = simplify(pts, 50) + expect(out[0]).toEqual(pts[0]) + expect(out[out.length - 1]).toEqual(pts[pts.length - 1]) + }) +}) +``` + +- [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm test +``` + +Erwartet: FAIL, „Failed to resolve import './gpx'" — die Datei gibt es noch nicht. + +- [ ] **Step 4: Den Parser schreiben** + +Datei `frontend/src/lib/gpx.ts`: + +```ts +/** + * GPX-Verarbeitung im Browser. + * + * Das Backend bleibt reines PocketBase ohne Hooks: Beim Upload liest das + * Frontend die Datei, rechnet Länge, Höhenmeter und Bounding-Box aus und + * schickt Originaldatei plus abgeleitete Werte gemeinsam an die API. + */ + +export type TrackPoint = { lat: number; lng: number; ele: number | null } + +export type ParsedGpx = { + points: TrackPoint[] + geojson: { type: 'LineString'; coordinates: [number, number][] } + elevation: { d: number; ele: number }[] + bounds: [[number, number], [number, number]] + distance_m: number + ascent_m: number +} + +/** Erdradius in Metern */ +const R = 6_371_000 + +/** + * Anstiege unterhalb dieser Schwelle gelten als GPS-Rauschen. Ohne sie + * summieren sich Messfehler zu absurden Höhenmetern — ein flacher Waldweg + * käme leicht auf mehrere hundert Meter. + */ +const ELE_NOISE_M = 3 + +/** Distanz zweier Punkte in Metern (Haversine). */ +export function haversine(a: TrackPoint, b: TrackPoint): number { + const toRad = (d: number) => (d * Math.PI) / 180 + const dLat = toRad(b.lat - a.lat) + const dLng = toRad(b.lng - a.lng) + const lat1 = toRad(a.lat) + const lat2 = toRad(b.lat) + + const h = + Math.sin(dLat / 2) ** 2 + + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2) + + return 2 * R * Math.asin(Math.sqrt(h)) +} + +/** + * Douglas-Peucker: entfernt Punkte, die kaum von der Verbindungslinie ihrer + * Nachbarn abweichen. Die Toleranz wird verdoppelt, bis das Ergebnis unter + * maxPoints liegt — so bleibt die Form erhalten, während die Datenmenge + * beherrschbar wird. + */ +export function simplify(points: TrackPoint[], maxPoints = 2000): TrackPoint[] { + if (points.length <= maxPoints) return points + + let tolerance = 0.00001 + let out = points + + // Obergrenze gegen Endlosschleifen bei entarteten Daten + for (let i = 0; i < 30 && out.length > maxPoints; i++) { + out = douglasPeucker(points, tolerance) + tolerance *= 2 + } + + return out +} + +function douglasPeucker(pts: TrackPoint[], tolerance: number): TrackPoint[] { + if (pts.length < 3) return pts + + let maxDist = 0 + let index = 0 + + for (let i = 1; i < pts.length - 1; i++) { + const d = perpendicularDistance(pts[i], pts[0], pts[pts.length - 1]) + if (d > maxDist) { + maxDist = d + index = i + } + } + + if (maxDist <= tolerance) return [pts[0], pts[pts.length - 1]] + + const left = douglasPeucker(pts.slice(0, index + 1), tolerance) + const right = douglasPeucker(pts.slice(index), tolerance) + return [...left.slice(0, -1), ...right] +} + +/** + * Abstand eines Punktes zur Geraden durch start und end — in Grad, nicht in + * Metern. Für den Vergleich innerhalb eines Tracks genügt das und spart die + * teure Projektion. + */ +function perpendicularDistance(p: TrackPoint, start: TrackPoint, end: TrackPoint): number { + const dx = end.lng - start.lng + const dy = end.lat - start.lat + + if (dx === 0 && dy === 0) { + return Math.hypot(p.lng - start.lng, p.lat - start.lat) + } + + const t = + ((p.lng - start.lng) * dx + (p.lat - start.lat) * dy) / (dx * dx + dy * dy) + const clamped = Math.max(0, Math.min(1, t)) + + return Math.hypot( + p.lng - (start.lng + clamped * dx), + p.lat - (start.lat + clamped * dy), + ) +} + +/** + * Liest eine GPX-Datei. Wirft mit einer für Nutzer verständlichen Meldung, + * wenn die Datei kein gültiges XML ist oder keine Punkte enthält. + */ +export function parseGpx(xml: string): ParsedGpx { + const doc = new DOMParser().parseFromString(xml, 'application/xml') + + if (doc.querySelector('parsererror')) { + throw new Error('Die Datei ist kein gültiges XML.') + } + + // Manche Programme exportieren Routen (rtept) statt Tracks (trkpt). + let nodes = Array.from(doc.getElementsByTagName('trkpt')) + if (nodes.length === 0) { + nodes = Array.from(doc.getElementsByTagName('rtept')) + } + + if (nodes.length === 0) { + throw new Error('Die GPX-Datei enthält keine Punkte.') + } + + const points: TrackPoint[] = [] + for (const n of nodes) { + const lat = Number(n.getAttribute('lat')) + const lng = Number(n.getAttribute('lon')) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue + + const eleText = n.getElementsByTagName('ele')[0]?.textContent + const ele = eleText != null && eleText.trim() !== '' ? Number(eleText) : null + + points.push({ lat, lng, ele: Number.isFinite(ele as number) ? ele : null }) + } + + if (points.length === 0) { + throw new Error('Die GPX-Datei enthält keine Punkte.') + } + + // Länge, Höhenprofil und Höhenmeter in einem Durchlauf + let distance = 0 + let ascent = 0 + const elevation: { d: number; ele: number }[] = [] + let lastCountedEle: number | null = points[0].ele + + if (points[0].ele != null) elevation.push({ d: 0, ele: points[0].ele }) + + for (let i = 1; i < points.length; i++) { + distance += haversine(points[i - 1], points[i]) + + const ele = points[i].ele + if (ele == null) continue + + elevation.push({ d: Math.round(distance), ele }) + + if (lastCountedEle == null) { + lastCountedEle = ele + } else if (ele - lastCountedEle >= ELE_NOISE_M) { + ascent += ele - lastCountedEle + lastCountedEle = ele + } else if (lastCountedEle - ele >= ELE_NOISE_M) { + // Abstieg: neuer Bezugspunkt, aber nicht gezählt + lastCountedEle = ele + } + } + + const lats = points.map((p) => p.lat) + const lngs = points.map((p) => p.lng) + + const simplified = simplify(points) + + return { + points: simplified, + geojson: { + type: 'LineString', + coordinates: simplified.map((p) => [p.lng, p.lat] as [number, number]), + }, + elevation, + bounds: [ + [Math.min(...lngs), Math.min(...lats)], + [Math.max(...lngs), Math.max(...lats)], + ], + distance_m: Math.round(distance), + ascent_m: Math.round(ascent), + } +} +``` + +- [ ] **Step 5: Tests laufen lassen — sie müssen jetzt bestehen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm test +``` + +Erwartet: alle Tests grün. + +Falls der Test „liest alle Trackpunkte" scheitert, weil `DOMParser` fehlt: Vitest läuft standardmäßig in Node ohne DOM. Dann `npm install -D jsdom` und in `vite.config.ts` bei `defineConfig` ergänzen: + +```ts + test: { + environment: 'jsdom', + }, +``` + +- [ ] **Step 6: Typprüfung** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -3 +``` + +Erwartet: 0 Fehler, 0 Warnungen. + +- [ ] **Step 7: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/lib/gpx.ts frontend/src/lib/gpx.test.ts frontend/package.json frontend/package-lock.json frontend/vite.config.ts +git commit -m "feat: GPX-Parser mit Länge, Höhenmetern und Streckenvereinfachung" +``` + +--- + +### Task 3: Stores für Trails und Versionen + +**Files:** +- Create: `frontend/src/lib/stores/trails.svelte.ts` +- Create: `frontend/src/lib/stores/trailVersions.svelte.ts` + +**Interfaces:** +- Consumes: `parseGpx` aus `$lib/gpx`; `getTeamContext()` aus `$lib/stores/teams.svelte`; `api` aus `$lib/stores/pocketbase.svelte` +- Produces: + ```ts + // trails.svelte.ts + export class TrailStore { + records: TrailsResponse[] + loading: boolean + error: string | null + get scoped(): TrailsResponse[] + getById(id: string): TrailsResponse | undefined + async load(): Promise + subscribe(): void + destroy(): void + async create(data: { name: string; description?: string; status?: string }): Promise + async edit(id: string, data: Partial): Promise + async remove(id: string): Promise + async setStewards(id: string, userIds: string[]): Promise + 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 + subscribe(): void + destroy(): void + async upload(trailId: string, file: File, note: string): Promise + async activate(trailId: string, versionId: string): Promise + async remove(id: string): Promise + } + export function setTrailVersionContext(): TrailVersionStore + export function getTrailVersionContext(): TrailVersionStore + ``` + +- [ ] **Step 1: `trails.svelte.ts` schreiben** + +Aufbau exakt nach dem Muster von `events.svelte.ts`. + +```ts +import { getContext, setContext } from 'svelte' +import { api } from './pocketbase.svelte' +import { auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import type { TrailsResponse } from '$lib/types' + +const KEY = Symbol('trail') + +export class TrailStore { + records = $state([]) + loading = $state(false) + error = $state(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) { + 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(KEY) +} +``` + +- [ ] **Step 2: `trailVersions.svelte.ts` schreiben** + +```ts +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { parseGpx } from '$lib/gpx' +import type { TrailVersionsResponse } from '$lib/types' + +const KEY = Symbol('trailVersion') + +/** Größer als das maxSize des gpx-Feldes wäre serverseitig ohnehin abgelehnt. */ +const MAX_GPX_BYTES = 10 * 1024 * 1024 + +export class TrailVersionStore { + records = $state([]) + loading = $state(false) + error = $state(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(KEY) +} +``` + +- [ ] **Step 3: Prüfen, dass `auth` exportiert wird** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +grep -n "export const auth\|export {.*auth" src/lib/stores/pocketbase.svelte.ts +``` + +Erwartet: eine Zeile mit `auth`. Fehlt sie, heißt der Export anders — dann in beiden neuen Dateien den Import entsprechend anpassen. + +- [ ] **Step 4: Typprüfung** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -5 +``` + +Erwartet: 0 Fehler, 0 Warnungen. + +- [ ] **Step 5: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/lib/stores/trails.svelte.ts frontend/src/lib/stores/trailVersions.svelte.ts +git commit -m "feat: Stores für Trails und GPX-Versionen" +``` + +--- + +### Task 4: Stores für Flags und Marker + +**Files:** +- Create: `frontend/src/lib/stores/trailFlags.svelte.ts` +- Create: `frontend/src/lib/stores/trailMarkers.svelte.ts` + +**Interfaces:** +- Consumes: `getTeamContext()`, `api`, `auth` +- Produces: + ```ts + // trailFlags.svelte.ts + export const DEFAULT_FLAGS: { label: string; icon: string; color: string; severity: string }[] + export class TrailFlagStore { + records: TrailFlagsResponse[] + loading: boolean + error: string | null + get scoped(): TrailFlagsResponse[] + getById(id: string): TrailFlagsResponse | undefined + async load(): Promise + subscribe(): void + destroy(): void + async create(data: { label: string; icon: string; color: string; severity: string }): Promise + async edit(id: string, data: Partial): Promise + async remove(id: string): Promise + async seedDefaults(): Promise + } + 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 + subscribe(): void + destroy(): void + async create(data: { trail: string; flag: string; lat: number; lng: number; note?: string }): Promise + async edit(id: string, data: Partial): Promise + async toggleResolved(id: string): Promise + async remove(id: string): Promise + } + export function setTrailMarkerContext(): TrailMarkerStore + export function getTrailMarkerContext(): TrailMarkerStore + ``` + +- [ ] **Step 1: `trailFlags.svelte.ts` schreiben** + +```ts +import { getContext, setContext } from 'svelte' +import { api } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import type { TrailFlagsResponse } from '$lib/types' + +const KEY = Symbol('trailFlag') + +/** + * Vorschlag für einen Standardsatz. Bewusst NICHT in der Migration: Seed-Daten + * dort kämen bei jedem Containerstart zurück, auch nachdem sie jemand gelöscht + * hat. Stattdessen legt sie die Flag-Verwaltung auf Knopfdruck an. + * + * icon: Name eines Lucide-Icons (lucide-svelte) + */ +export const DEFAULT_FLAGS = [ + { label: 'Baum quer', icon: 'TreePine', color: '#ca8a04', severity: 'warnung' }, + { label: 'Verblockt', icon: 'Blocks', color: '#ea580c', severity: 'warnung' }, + { label: 'Erosion', icon: 'Waves', color: '#a16207', severity: 'warnung' }, + { label: 'Sperrung', icon: 'Ban', color: '#dc2626', severity: 'kritisch' }, + { label: 'Bauarbeiten', icon: 'Construction', color: '#dc2626', severity: 'kritisch' }, + { label: 'Hinweis', icon: 'Info', color: '#2563eb', severity: 'info' }, +] + +export class TrailFlagStore { + records = $state([]) + loading = $state(false) + error = $state(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) { + 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 { + 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(KEY) +} +``` + +- [ ] **Step 2: `trailMarkers.svelte.ts` schreiben** + +```ts +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import type { TrailMarkersResponse } from '$lib/types' + +const KEY = Symbol('trailMarker') + +export class TrailMarkerStore { + records = $state([]) + loading = $state(false) + error = $state(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) { + 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(KEY) +} +``` + +- [ ] **Step 3: Typprüfung** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -5 +``` + +Erwartet: 0 Fehler, 0 Warnungen. + +- [ ] **Step 4: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/lib/stores/trailFlags.svelte.ts frontend/src/lib/stores/trailMarkers.svelte.ts +git commit -m "feat: Stores für Flag-Typen und Trail-Marker" +``` + +--- + +### Task 5: Stores registrieren und Navigation ergänzen + +**Files:** +- Modify: `frontend/src/routes/dashboard/+layout.svelte` + +**Interfaces:** +- Consumes: die vier `setXContext()`-Funktionen aus Task 3 und 4 +- Produces: Die Stores sind in allen Dashboard-Routen über `getTrailContext()` usw. erreichbar; die Navigation enthält einen Eintrag „Trails". + +- [ ] **Step 1: Imports ergänzen** + +In `frontend/src/routes/dashboard/+layout.svelte` bei den übrigen Store-Imports: + +```ts +import { setTrailContext } from '$lib/stores/trails.svelte' +import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte' +import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte' +import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' +``` + +Beim Icon-Import aus `lucide-svelte` zusätzlich `Route` aufnehmen (für den Navigationseintrag). + +- [ ] **Step 2: Stores anlegen** + +Nach `const times = setTimeContext()` ergänzen. Die Reihenfolge ist wichtig: +`setTrailContext()` ruft intern `getTeamContext()` auf, `teams` muss also +vorher existieren — das ist bereits der Fall. + +```ts + const trails = setTrailContext() + const trailVersions = setTrailVersionContext() + const trailFlags = setTrailFlagContext() + const trailMarkers = setTrailMarkerContext() +``` + +- [ ] **Step 3: Laden und Subscriben ergänzen** + +Die bestehende Zeile + +```ts + await Promise.all([events.load(), runs.load(), riders.load(), times.load()]) +``` + +ersetzen durch + +```ts + await Promise.all([ + events.load(), runs.load(), riders.load(), times.load(), + trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(), + ]) +``` + +und nach `times.subscribe()` ergänzen: + +```ts + trails.subscribe() + trailVersions.subscribe() + trailFlags.subscribe() + trailMarkers.subscribe() +``` + +Zur Sicherheit: Die Collections sind verschieden, deshalb greift die +Auto-Cancellation des SDK nicht — sie gruppiert nach Methode und Pfad. + +- [ ] **Step 4: Navigationseintrag ergänzen** + +Im Array `nav` nach dem Events-Eintrag: + +```ts + { href: '/dashboard/trails', icon: Route, label: 'Trails' }, +``` + +- [ ] **Step 5: Prüfen, dass die App noch startet** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -3 +``` + +Erwartet: 0 Fehler, 0 Warnungen. + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/backend && docker compose up -d +cd ../frontend && npm run dev +``` + +Im Browser `http://stammtisch-hersbruck.de.localhost:31337/dashboard` öffnen, +anmelden. Erwartet: Die Seite lädt, in der Navigation steht „Trails", und die +Konsole zeigt keine Fehler zu `trail_*`-Collections. Ein Klick auf „Trails" +führt zu einem 404 — die Route entsteht erst in Task 7. + +Danach den Dev-Server mit `Ctrl+C` beenden. + +- [ ] **Step 6: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/routes/dashboard/+layout.svelte +git commit -m "feat: Trail-Stores registrieren und Navigationseintrag ergänzen" +``` + +--- + +### Task 6: Kartenkomponente und Höhenprofil + +**Files:** +- Create: `frontend/src/lib/components/TrailMap.svelte` +- Create: `frontend/src/lib/components/ElevationProfile.svelte` +- Modify: `frontend/package.json` (maplibre-gl) + +**Interfaces:** +- Consumes: nichts aus den Stores — beide Komponenten bekommen alles über Props +- Produces: + ```svelte + + 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' + } + + + Props: { + elevation: { d: number; ele: number }[] + onhover?: (index: number | null) => void + height?: string // Default '120px' + } + ``` + +- [ ] **Step 1: MapLibre installieren** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm install maplibre-gl +``` + +- [ ] **Step 2: `TrailMap.svelte` schreiben** + +```svelte + + +
+ +{#if placing} +

+ Klicke auf die Karte, um die Stelle zu markieren. +

+{/if} +``` + +- [ ] **Step 3: `ElevationProfile.svelte` schreiben** + +Kein Diagramm-Paket — die Daten sind eine Zahlenreihe, ein SVG-Pfad genügt. + +```svelte + + +{#if stats} +
+ + + {#if hoverX !== null} + + {/if} + +
+
+ {Math.round(stats.min)} m + {(stats.totalD / 1000).toFixed(1)} km + {Math.round(stats.max)} m +
+{:else} +

Keine Höhendaten vorhanden.

+{/if} +``` + +- [ ] **Step 4: Typprüfung** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -5 +``` + +Erwartet: 0 Fehler, 0 Warnungen. + +- [ ] **Step 5: Svelte-Code validieren** + +Nutze den Svelte-MCP-Server (`mcp__svelte__svelte-autofixer`) für beide neuen +Komponenten. Er prüft, ob die Runes-Syntax korrekt ist. Gemeldete Probleme +beheben und erneut prüfen, bis nichts mehr offen ist. + +- [ ] **Step 6: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/lib/components/TrailMap.svelte frontend/src/lib/components/ElevationProfile.svelte frontend/package.json frontend/package-lock.json +git commit -m "feat: Kartenkomponente mit MapLibre und Höhenprofil" +``` + +--- + +### Task 7: Trail-Liste + +**Files:** +- Create: `frontend/src/routes/dashboard/trails/+page.svelte` + +**Interfaces:** +- Consumes: `getTrailContext()`, `getTrailVersionContext()`, `getTrailMarkerContext()`, `TrailMap.svelte` +- Produces: Route `/dashboard/trails` + +- [ ] **Step 1: Seite schreiben** + +```svelte + + +
+
+
+

Trails

+

+ {trails.scoped.length} + {trails.scoped.length === 1 ? 'Trail' : 'Trails'} im Team +

+
+ +
+ + {#if trails.loading} +

Trails werden geladen …

+ {:else if trails.scoped.length === 0} + + + +

+ Noch kein Trail angelegt. Leg einen an und lade eine GPX-Datei hoch. +

+ +
+
+ {:else} + + {/if} +
+ + + + + Neuer Trail + + Die GPX-Datei lädst du im nächsten Schritt hoch. + + + +
+
+ + +
+
+ + +
+ {#if error} +

{error}

+ {/if} +
+ + + + + +
+
+``` + +- [ ] **Step 2: Typprüfung und Svelte-Validierung** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -5 +``` + +Erwartet: 0 Fehler, 0 Warnungen. Zusätzlich die Datei durch +`mcp__svelte__svelte-autofixer` prüfen. + +- [ ] **Step 3: Im Browser prüfen** + +Backend starten, Dev-Server starten, anmelden, `/dashboard/trails` öffnen. + +Erwartet: Leerzustand mit dem Hinweis „Noch kein Trail angelegt". Über „Neuer +Trail" einen anlegen — die App springt danach auf die Detailseite, die noch +404 liefert (entsteht in Task 8). Zurück auf `/dashboard/trails`: Der Trail +erscheint als Karte mit dem Hinweis „Noch keine GPX-Datei". + +- [ ] **Step 4: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/routes/dashboard/trails/+page.svelte +git commit -m "feat: Trail-Liste mit Vorschaukarten" +``` + +--- + +### Task 8: Trail-Detailseite + +**Files:** +- Create: `frontend/src/routes/dashboard/trails/[id]/+page.svelte` + +**Interfaces:** +- Consumes: alle vier Trail-Stores, `TrailMap.svelte`, `ElevationProfile.svelte`, `getFileURL` aus `$lib/stores/pocketbase.svelte` +- Produces: Route `/dashboard/trails/[id]` + +- [ ] **Step 1: Seite schreiben** + +```svelte + + +{#if !trail} +
+ +

Trail nicht gefunden.

+
+{:else} +
+ +
+
+ +

{trail.name}

+ {#if trail.description} +

{@html trail.description}

+ {/if} +
+ +
+ + {STATUS_LABEL[trail.status] ?? trail.status} + + {#if canEdit} + + {/if} +
+
+ + {#if canEdit} +
+ {#each ['offen', 'eingeschraenkt', 'gesperrt'] as s} + + {/each} +
+ {/if} + + + + + {#if version?.geojson} + + +
+ {(version.distance_m / 1000).toFixed(1)} km + {version.ascent_m} hm + +
+ + {#if flags.scoped.length === 0} +

+ Es gibt noch keine Flag-Typen. + Jetzt anlegen +

+ {/if} + + {#if version.elevation?.length} + + (highlight = i)} + /> + {/if} + {:else} +
+ +

Für diesen Trail gibt es noch keine GPX-Datei.

+ {#if canEdit} + + {/if} +
+ {/if} +
+
+ + +
+ + + +
+ + {#if tab === 'marker'} + {#if trailMarkers.length === 0} +

+ Noch keine Marker. Setze einen über „Marker setzen" auf der Karte. +

+ {:else} +
+ {#each trailMarkers as m (m.id)} + {@const flag = flags.getById(m.flag)} + + + +
+

{flag?.label ?? 'Unbekannt'}

+ {#if m.note} +

{m.note}

+ {/if} +

{formatDate(m.created)}

+
+ +
+
+ {/each} +
+ {/if} + {:else if tab === 'kommentare'} +
+
+ e.key === 'Enter' && addComment()} + /> + +
+ {#if commentError} +

{commentError}

+ {/if} + + {#if comments.length === 0} +

Noch keine Kommentare.

+ {:else} +
+ {#each comments as c (c.id)} + + +

{c.text}

+

{formatDate(c.created)}

+
+
+ {/each} +
+ {/if} +
+ {:else} + {#if trailVersions.length === 0} +

Noch keine Version hochgeladen.

+ {:else} +
+ {#each trailVersions as v (v.id)} + + +
+
+

{formatDate(v.created)}

+ {#if v.id === trail.current} + Aktiv + {/if} +
+

+ {(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm + {#if v.note}· {v.note}{/if} +

+
+ + {#if v.gpx} + + {/if} + + {#if canEdit && v.id !== trail.current} + + {/if} +
+
+ {/each} +
+ {/if} + {/if} +
+{/if} + + + + + + GPX-Datei hochladen + + Die bisherige Version bleibt erhalten; die neue wird zur aktiven. + + + +
+
+ + (uploadFile = e.currentTarget.files?.[0] ?? null)} + /> +
+
+ + +
+ {#if uploadError} +

{uploadError}

+ {/if} +
+ + + + + +
+
+ + + + + + Marker setzen + + +
+
+ +
+ {#each flags.scoped as f (f.id)} + + {/each} +
+
+
+ + +
+ {#if markerError} +

{markerError}

+ {/if} +
+ + + + + +
+
+``` + +- [ ] **Step 2: Typprüfung und Svelte-Validierung** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -5 +``` + +Erwartet: 0 Fehler, 0 Warnungen. Zusätzlich die Datei durch +`mcp__svelte__svelte-autofixer` prüfen. + +- [ ] **Step 3: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/routes/dashboard/trails/ +git commit -m "feat: Trail-Detailseite mit Karte, Höhenprofil, Markern und Kommentaren" +``` + +--- + +### Task 9: Flag-Verwaltung + +**Files:** +- Create: `frontend/src/routes/dashboard/settings/flags/+page.svelte` + +**Interfaces:** +- Consumes: `getTrailFlagContext()`, `DEFAULT_FLAGS` +- Produces: Route `/dashboard/settings/flags` + +- [ ] **Step 1: Seite schreiben** + +```svelte + + +
+
+
+

Flag-Typen

+

+ Wofür sich Marker auf einem Trail setzen lassen. +

+
+
+ {#if flags.scoped.length === 0} + + {/if} + +
+
+ + {#if flags.scoped.length === 0} + + + Noch keine Flag-Typen. Leg einen an oder starte mit dem Standardsatz. + + + {:else} +
+ {#each flags.scoped as flag (flag.id)} + + + + {flag.label} + + {flag.severity} + + + + + + {/each} +
+ {/if} +
+ + + + + {editing ? 'Flag-Typ bearbeiten' : 'Neuer Flag-Typ'} + + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ {#each SEVERITIES as s} + + {/each} +
+
+
+ + +

+ Name eines Icons von lucide.dev, in Schreibweise wie „TreePine". +

+
+ {#if error} +

{error}

+ {/if} +
+ + + + + +
+
+``` + +- [ ] **Step 2: Typprüfung und Svelte-Validierung** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm run check 2>&1 | tail -5 +``` + +Erwartet: 0 Fehler, 0 Warnungen. Zusätzlich durch `mcp__svelte__svelte-autofixer` prüfen. + +- [ ] **Step 3: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add frontend/src/routes/dashboard/settings/ +git commit -m "feat: Verwaltung der Flag-Typen" +``` + +--- + +### Task 10: Durchgehender Test im Browser + +**Files:** +- Keine Änderungen — reine Verifikation. Gefundene Fehler werden behoben und einzeln committet. + +**Interfaces:** +- Consumes: alle vorherigen Tasks +- Produces: Nachweis, dass das Feature vollständig funktioniert + +- [ ] **Step 1: Eine echte GPX-Testdatei besorgen** + +Falls keine zur Hand ist, eine erzeugen: + +```bash +cat > /tmp/testtrail.gpx <<'EOF' + + + Hersbrucker Testrunde + 340 + 355 + 390 + 420 + 405 + 370 + 350 + 342 + 338 + + +EOF +echo "Testdatei unter /tmp/testtrail.gpx" +``` + +- [ ] **Step 2: Umgebung starten** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/backend && docker compose up -d +until curl -s http://127.0.0.1:8090/api/health >/dev/null 2>&1; do sleep 1; done +cd ../frontend && npm run dev +``` + +`frontend/.env` muss dabei auf `PUBLIC_PB_URL=http://127.0.0.1:8090` zeigen. +Nach dem Ändern der Datei den Dev-Server neu starten — die Variable wird zur +Buildzeit eingesetzt. + +- [ ] **Step 3: Die Durchstich-Prüfung** + +Im Browser anmelden und der Reihe nach: + +1. `/dashboard/settings/flags` → „Standardsatz anlegen" → sechs Typen erscheinen +2. `/dashboard/trails` → „Neuer Trail" → Name eingeben → landet auf der Detailseite +3. „GPX hochladen" → `/tmp/testtrail.gpx` wählen → Track erscheint auf der Karte, Kartenausschnitt passt +4. Kennzahlen prüfen: rund 3–4 km, etwa 110 Höhenmeter +5. Höhenprofil erscheint; beim Überfahren wandert ein blauer Punkt über den Track +6. „Marker setzen" → auf die Karte klicken → Typ wählen → speichern → Punkt erscheint +7. Marker im Reiter „Marker" als erledigt markieren → wird blass +8. Kommentar schreiben → erscheint sofort in der Liste +9. Zweite GPX-Version hochladen (dieselbe Datei genügt) → Reiter „Versionen" zeigt zwei, die neue ist aktiv; **Marker und Kommentar sind noch da** +10. Auf die erste Version „Aktivieren" → Karte wechselt zurück +11. Status auf „Gesperrt" setzen → die Linie wird rot, in der Liste erscheint das Badge +12. `/dashboard/trails` → Vorschaukarte zeigt den Track, offene Marker werden gezählt + +- [ ] **Step 4: Realtime prüfen** + +Ein zweites Browserfenster auf derselben Trail-Detailseite öffnen. Im ersten +Fenster einen Kommentar schreiben. + +Erwartet: Der Kommentar erscheint im zweiten Fenster ohne Neuladen. + +- [ ] **Step 5: Rechte prüfen** + +Dafür braucht es einen zweiten Nutzer im selben Team, der **nicht** Pate des +Trails und **nicht** Team-Admin ist. Anlegen im PocketBase-Admin unter +`http://127.0.0.1:8090/_/` (Collection `users`), dann im Team als Mitglied +eintragen. + +Mit diesem Nutzer anmelden und die Trail-Detailseite öffnen. + +Erwartet: +- „GPX hochladen" und die Status-Knöpfe sind **nicht** sichtbar +- Marker setzen und Kommentieren funktioniert +- Ein direkter Schreibversuch scheitert serverseitig. Das lässt sich am + einfachsten mit `curl` prüfen. Dazu den Auth-Token des zweiten Nutzers + besorgen — in der Browser-Konsole auf der angemeldeten Seite: + ```js + JSON.parse(localStorage.getItem('pocketbase_auth')).token + ``` + Damit dann, mit der ID eines Trails, dessen Pate der Nutzer NICHT ist: + ```bash + curl -s -o /dev/null -w "%{http_code}\n" \ + -X PATCH "http://127.0.0.1:8090/api/collections/trails/records/" \ + -H "Authorization: " \ + -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 `` hochladen → Meldung „Die GPX-Datei enthält keine Punkte." +3. Einen Flag-Typ löschen, der von einem Marker verwendet wird → verständliche Meldung, kein stiller Fehlschlag + +- [ ] **Step 7: Abschließende Prüfungen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/frontend +npm test 2>&1 | tail -5 +npm run check 2>&1 | tail -3 +``` + +Erwartet: Alle Tests grün, 0 Fehler und 0 Warnungen. + +- [ ] **Step 8: Aufräumen und Doku ergänzen** + +Dev-Server und Container stoppen: + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de/backend && docker compose down +``` + +In `CLAUDE.md` im Abschnitt „Backend Integration (PocketBase)" die +Collections-Zeile ergänzen: + +```markdown +- **Collections**: users, teams, events, runs, riders, times, trails, + trail_versions, trail_flags, trail_markers, trail_comments +``` + +Und unter „Important Patterns" ergänzen: + +```markdown +**Karten**: `TrailMap.svelte` und `ElevationProfile.svelte` sind reine +Präsentationskomponenten ohne Store-Zugriff — Daten kommen über Props, +Ereignisse über Callback-Props zurück. GPX wird im Browser geparst +(`$lib/gpx.ts`), das Backend bleibt ohne Hooks. +``` + +- [ ] **Step 9: Committen** + +```bash +cd /home/dne/Projekte/stammtisch-hersbruck.de +git add CLAUDE.md +git status --short +git commit -m "docs: Trail-Collections und Kartenmuster in CLAUDE.md ergänzen" +``` + +**Kein `git push`** — der bleibt ausdrücklicher Anweisung vorbehalten. + +--- + +## Abschlussbericht + +Nach Task 10 an den Nutzer berichten: + +- Welche Schritte der Durchstich-Prüfung erfolgreich waren +- Die gemessenen Kennzahlen der Testdatei (Länge, Höhenmeter) und ob sie plausibel sind +- Ergebnis der Rechteprüfung aus Step 5 — insbesondere, ob der Schreibversuch serverseitig abgelehnt wurde +- Falls ein Punkt nicht geprüft werden konnte (etwa mangels zweitem Nutzer): ausdrücklich als **ungeprüft** benennen, nicht als erledigt +- Dass nicht gepusht wurde