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