Compare commits

...

13 commits

Author SHA1 Message Date
Daniel Michelberger
27d34ad0d3 feat: Superadmin-Rolle und Standard-Flags fuer neue Teams
Die Rolle superadmin kommt als Feld an users, der erste ist
stammtisch@dne.name. Vergeben wird sie nur in PocketBase: Die updateRule
schliesst das Feld ueber @request.body.superadmin:isset = false aus, sonst
koennte sich jeder selbst befoerdern - jeder darf schliesslich seinen
eigenen Datensatz aendern. Besondere Rechte in der App haengen noch nicht
daran.

Flag-Typen bleiben ausdruecklich Sache der Teams. Ein neues Team bekommt
beim Anlegen den hartkodierten Standardsatz aus der App und kann ihn danach
frei aendern; eine appweite Verwaltung entfaellt damit. Ein Team ohne
Flag-Typen koennte zwar Marker aufnehmen, aber nichts einordnen.

Im Kopf stehen Avatar und Name links, Design-Umschalter und Logout rechts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 15:03:49 +02:00
Daniel Michelberger
561d7cbbc7 docs: Symptom der noch nicht gelaufenen Migrationen in TODO.md benennen
"Only superusers can perform this action" beim Anlegen eines Mitglieds ist
kein Fehler im Frontend, sondern die unveraenderte users.createRule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 13:13:39 +02:00
Daniel Michelberger
27c83946df feat: Erzeugte Avatare und Verfasser an Kommentaren
Ohne hinterlegtes Bild stehen Initialen auf einem Farbton, der sich aus der
User-ID errechnet: Derselbe Mensch bekommt ueberall dasselbe Bild, ohne dass
es irgendwo gespeichert werden muss. Bewusst kein externer Avatar-Dienst -
der wuerde bei jedem Seitenaufruf verraten, wer hier unterwegs ist.

Kommentare zeigen jetzt ihren Verfasser, aber nur als Bild; der Name steht
im Tooltip. In einer schmalen Spalte neben der Karte ist fuer mehr kein
Platz.

Verwendet wird der Avatar ausserdem in der Kopfzeile, im Profil und in der
Mitgliederliste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 13:13:22 +02:00
Daniel Michelberger
fe8e301e65 feat: Marker mit eigenem Typ und frei waehlbarem Icon
Auf dem Trail passt nicht immer ein vorgefertigter Typ - "Wespennest am
Anlieger" legt niemand vorher an. Der Marker-Dialog fuehrt deshalb ein
Dropdown, dessen erste Zeile "Eigener Typ" ist und ein Textfeld oeffnet.

Das Icon steht ab jetzt am Marker selbst. Ein gewaehlter Typ schlaegt seines
vor, ueberschreiben laesst es sich jederzeit: Dieselbe Art Hindernis sieht
nicht immer gleich aus. Der Picker ist deshalb immer sichtbar und wandert
als eigene Komponente auch in die Flag-Verwaltung.

Schema: trail_markers.flag ist nicht mehr Pflicht, dafuer gibt es label und
icon. Bestehende Marker bleiben gueltig - ohne eigene Angaben faellt die
Anzeige auf den Typ zurueck.

Damit braucht ein Trail auch keine Flag-Typen mehr, um ueberhaupt Meldungen
aufnehmen zu koennen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 13:13:22 +02:00
Daniel Michelberger
6b1c57ba17 docs: Offene Punkte und Deploy-Schritte in TODO.md festhalten
Vier Entscheidungen stehen aus (Rollenmodell mit Superadmin, appweite
Flag-Typen, Melderecht fuer den Trail-Status, Kader statt Event-Fahrer) und
drei Dinge passieren ausserhalb des Repos: PUBLIC_CARTO_API_KEY in Coolify,
die drei neuen Migrationen samt Datenverlust bei events.status, und einmal
Typen neu erzeugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:59:57 +02:00
Daniel Michelberger
f8247ae3e7 feat: Trail-Seite neu geordnet, echte dunkle Karte, sichtbare Flag-Icons
Der Kopf traegt jetzt Name, Zustand und Kennzahlen: Der Status sitzt als
farbiges Dropdown direkt hinter dem Namen und nennt darunter, wann und von
wem er gemeldet wurde. Laenge und Hoehenmeter stehen mit Icons daneben.

Karte und Hoehenprofil links, Marker, Kommentare und Versionen rechts
daneben in einer Spalte. Die Reiter entfallen - alles ist gleichzeitig zu
sehen. Versionen sind Archiv und deshalb eingeklappt; der GPX-Upload sitzt
dort statt im Kopf.

Marker lassen sich jetzt auch loeschen und auch ueber das Hoehenprofil
setzen: Die angeklickte Stelle wird auf dieselbe Linie zurueckgerechnet,
die ein Klick auf der Karte trifft.

Die Flag-Icons wurden bisher gespeichert, aber nirgends gezeichnet. Sie
erscheinen jetzt in der Karte, im Popup, in der Markerliste und in der
Verwaltung. Erlaubt ist jeder Name von lucide.dev - moeglich macht das ein
Glob ueber die Icon-Dateien, der jedes Icon zu einem eigenen, erst bei
Bedarf geladenen Chunk macht. Die Liste im Dialog sind nur Vorschlaege.

Die Karte kommt von CARTO statt von OSM direkt: zurueckhaltend gezeichnet,
sodass die Trail-Linie darueber steht, und mit einer echten dunklen
Fassung. Das vorherige Abdunkeln der OSM-Kacheln ergab nur ein dunkleres
Bild, keine dunkle Karte. In der Kachelvorschau der Liste sind Zoom,
Massstab und Quellenangabe abgeschaltet - dort ist die Karte Bild, nicht
Werkzeug; die grosse Karte traegt die Angabe weiterhin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:56:17 +02:00
Daniel Michelberger
7c3cf29354 feat: Einstellungen als Reiter, Fahrerverzeichnis, datierte Events
Die Einstellungen bekommen Reiter: Profil, Team, Fahrer (nur Teamleitung)
und Flag-Typen. /dashboard/teams wandert dorthin.

Neu ist die Hauptroute Fahrer - das Verzeichnis des Teams, fuer alle
sichtbar und fuer niemanden dort aenderbar. Angelegt, geaendert und mit
einem Konto verknuepft wird in den Einstellungen; serverseitig setzen das
die neuen Regeln der Collection durch.

Ein Fahrer bekommt sein Login entweder aus den vorhandenen Teammitgliedern
oder direkt neu angelegt. Die Einladung per E-Mail-Suche entfaellt: Sie
konnte nie funktionieren, weil users.listRule nur den eigenen Datensatz
freigibt - daher auch die abgeschnittenen IDs in der alten Mitgliederliste.

Events zeigen statt eines Status ihr Datum, mit Checkbox fuer mehrtaegig.
Gespeichert wird ein Enddatum nur, wenn es sich vom Beginn unterscheidet -
es ist damit zugleich das Kennzeichen fuer mehrtaegig statt einer zweiten
Wahrheit ueber dieselbe Sache. Listen gruppieren nach laufend, anstehend,
ohne Termin und vorbei.

Im Kopf teilen sich Name, Design-Umschalter und Logout eine Zeile, das
Team-Dropdown sitzt darunter; das Logo passt jetzt in die Leiste, statt
beschnitten zu wirken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:56:01 +02:00
Daniel Michelberger
1cd708e0ff feat(schema): Fahrerkonten, Event-Daten und Herkunft des Trail-Status
Drei Migrationen, dazu der Snapshot in 1754400000_init_schema.js, aus dem
die TypeScript-Typen erzeugt werden - beide muessen zusammenpassen, sonst
laufen Schema und Typen auseinander.

riders.user verknuepft einen Fahrer mit einem Login, ohne cascadeDelete:
Ein geloeschtes Konto darf keine Zeiten mitreissen. Anlegen, Aendern und
Loeschen von Fahrern wird zur Sache der Teamleitung; bisher durfte das
jedes Teammitglied.

users.viewRule von "nur ich selbst" auf "eingeloggt" - ohne das laesst sich
zu einer bekannten ID kein Name anzeigen, was Mitgliederliste,
Fahrerkonten und den Melder eines Trail-Markers betrifft. Die listRule
bleibt eng, E-Mails bleiben ueber emailVisibility verborgen. users.createRule
erlaubt eingeloggten Nutzern das Anlegen von Konten, nicht der ganzen Welt.

events verliert status und bekommt starts/ends. Der Status war Handarbeit:
Wer vergass, ein Event abzuschliessen, hatte eine falsche Uebersicht. Ein
Datum weiss von selbst, was ansteht, laeuft und vorbei ist. ACHTUNG: Die
Migration entfernt das Feld samt seiner Werte.

trails bekommt status_changed und status_by. Einer Sperrung soll man
ansehen, ob sie von heute Morgen oder vom letzten Herbst ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:55:47 +02:00
Daniel Michelberger
cc55bd66a5 feat: Ein Dialog fuer die Zeitnahme, keine Aktionen mehr inline
Start und Stopp laufen ausschliesslich ueber TimeDialog. Der fuehrt immer
beide Angaben - Fahrer und Run -, auch wenn eine davon aus dem Kontext
vorbelegt ist: Beim Stoppen an der Strecke soll kein Zweifel bleiben, wen
man gerade erwischt.

DNF fragt nach, bevor es schreibt. Eine Zeit ohne Wertung laesst sich nicht
zurueckholen, ein Fehlgriff waere also endgueltig.

Tabelle und Toast-Leiste zeigen damit nur noch an; ihre Knoepfe oeffnen den
Dialog mit passender Vorbelegung. Der alte Stopp-Dialog und der
Fahrerlisten-Start entfallen - beides waren Ein-Tipp-Aktionen und damit
genau das, was hier nicht mehr sein soll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:55:33 +02:00
Daniel Michelberger
514b5de27b feat: Laufende Zeiten in der Rangliste und als sticky Leiste, Monospace raus
Die eigene Karte "Laufende Zeiten" entfaellt: Laufende stehen jetzt oben in
derselben Tabelle wie die gewerteten, mit "laeuft"-Badge statt Platzziffer.
Die Zeile ist Klickziel fuer den Stopp-Dialog, der Knopf daneben macht sie
auch per Tastatur bedienbar.

Neu ist eine dauerhaft sichtbare Leiste ueber allen Dashboard-Seiten,
solange irgendwo eine Zeit laeuft - bei mehreren eingeklappt mit Anzahl und
laengster Zeit, aufklappbar. Damit bleibt die Uhr im Blick, waehrend man
zwischen zwei Startern woanders unterwegs ist.

Der Stopp-Dialog wandert dafuer in eine eigene Komponente, die sich
Run-Seite und Leiste teilen, statt die Logik zweimal zu haben.

Monospace faellt appweit weg; einzige Ausnahme bleibt die grosse mitlaufende
Uhr im Stopp-Dialog. tabular-nums bleibt stehen: gleich breite Ziffern
derselben Schrift, ohne die eine laufende Uhr bei jedem Zehntel springt.

Tabellenkoepfe hatten den Hover-Hintergrund der Body-Zeilen geerbt; die
Regel sitzt jetzt im thead und gilt fuer alle Tabellen der App.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:13:00 +02:00
Daniel Michelberger
245f8d7924 feat: Zeitnahme und Ergebnisse an Run und Fahrer statt an eigene Seiten
Die Routen /dashboard/timing und /dashboard/results entfallen samt ihrer
Menueeintraege. Beides passiert jetzt dort, wo es hingehoert: am Run
innerhalb seines Events.

Die Run-Seite zeigt laufende Zeiten in Echtzeit, jede Zeile ist Klickziel
fuer den Stopp-Dialog (Stoppen oder DNF). Gestartet wird ueber einen Dialog
mit Fahrersuche. Dazu Rangliste mit CSV-Export und ein Abschnitt fuer
Zeiten ohne Wertung.

Die Fahrer-Seite zeigt alle Runs des Events aus Sicht eines Fahrers, mit
Zeit, Status und Platz - auch Runs ohne Zeit, damit sichtbar bleibt, was
fehlt.

enrich() im times-Store nimmt now als Parameter: Ohne das waere Date.now()
fuer Svelte keine Abhaengigkeit und laufende Zeiten stuenden still. Der
neue ticker() liefert diesen Wert, wodurch die {#key tick}-Bloecke der
alten Zeitnahmeseite entfallen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:08:14 +02:00
Daniel Michelberger
a8049e7406 feat: Marker nur noch auf dem Trail setzen, Karte und Hoehenprofil koppeln
Die Trail-Linie ist jetzt selbst die Bedienflaeche: Eine unsichtbare breite
Kopie der Linie faengt Hover und Klick ab, deshalb kann daneben gar nichts
mehr passieren. Geklickte Punkte werden auf die Linie projiziert, der
Setzmodus samt Button entfaellt.

Beim Ueberfahren wandert der Punkt in Karte und Hoehenprofil synchron mit,
inklusive Anzeige von Hoehe und Streckenkilometer. Die Kopplung laeuft in
beide Richtungen ueber die Distanz und interpoliert zwischen den
Stuetzpunkten, statt zum naechsten Vertex zu springen.

Ein Klick auf einen vorhandenen Marker oeffnet kein Setzen-Fenster mehr,
sondern ein Popup mit Typ, Notiz, Zeitpunkt und Melder. Fremde User-Namen
bleiben vorerst verborgen, weil users.listRule nur den eigenen Datensatz
freigibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 12:08:02 +02:00
Daniel Michelberger
f29361b7f3 fix: E-Mail-Adresse aus dem Dashboard-Header entfernen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 11:39:56 +02:00
47 changed files with 4459 additions and 1401 deletions

100
TODO.md Normal file
View file

@ -0,0 +1,100 @@
# Offene Punkte
Stand: 2026-09-02. Was hier steht, ist bewusst nicht erledigt — entweder weil
es eine Entscheidung braucht oder weil es außerhalb des Repos passiert.
## Entscheidungen
### Trail-Status: dürfen Mitglieder melden?
Anzeige und Herkunft (`status_changed`, `status_by`) sind fertig. Ändern darf
den Status weiterhin nur, wer Pate oder Team-Admin ist — `trails.updateRule`
lässt nichts anderes zu.
Sollen alle Teammitglieder melden dürfen, braucht es eine Regel, die genau ein
Feld freigibt: `team.users.id ?= @request.auth.id` kombiniert mit
`@request.body.<feld>:isset = false` für **jedes** andere Feld. Das
funktioniert, wird aber lang und bricht still, sobald ein Feld dazukommt.
Deshalb erst nach ausdrücklicher Entscheidung.
### Fahrer: Kader oder Event-Teilnehmer?
`riders.event` bindet einen Fahrer an genau ein Event. Ein in den Einstellungen
angelegter Fahrer ohne Event steht deshalb nur im Verzeichnis und taucht in
keiner Zeitnahme auf; der Dialog hat dafür ein optionales Event-Feld.
Ein echter Team-Kader, aus dem man Fahrer in Events übernimmt, wäre eine
n:m-Beziehung — also eine Schema-Änderung, keine Oberflächenfrage.
### Mitglied per E-Mail einladen
Die alte Einladung über eine E-Mail-Suche ist entfallen und durch „Mitglied
anlegen" ersetzt. Sie konnte nie funktionieren: `users.listRule` gibt nur den
eigenen Datensatz frei, die Suche lief immer ins Leere.
Ein **bestehendes** fremdes Konto in ein Team zu holen braucht eine offenere
`listRule` (z. B. `@request.auth.id != ""`). Das erlaubt allerdings, alle
Konten der Instanz aufzulisten — deshalb bewusst nicht ohne Rücksprache.
## Vor dem nächsten Deploy
### `PUBLIC_CARTO_API_KEY` in Coolify setzen
Die Kartenkacheln kommen von CARTO. Der Schlüssel muss den Browser erreichen,
deshalb das `PUBLIC_`-Präfix — ohne das reicht SvelteKit ihn nicht weiter.
Gelesen wird er über `$env/dynamic/public`, das heißt: Die Variable muss in der
**Laufzeitumgebung** des `node build`-Prozesses stehen, nicht nur zur Buildzeit.
In Coolify als normale Umgebungsvariable eintragen genügt. Fehlt sie, laufen die
Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
### Migrationen greifen beim nächsten Start
Fünf neue Migrationen liegen in `backend/pb_migrations/`:
- `1754500500_riders_user_and_admin_rules.js`
- `1754500600_events_dates_instead_of_status.js`
- `1754500700_trail_status_meta.js`
- `1754500800_trail_markers_custom.js`
- `1754500900_users_superadmin.js`
Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins:
**„Only superusers can perform this action"** — genau das sagt die noch
unveraenderte `users.createRule` (null = nur Superuser).
**Achtung:** Die mittlere entfernt `events.status` **samt seiner Werte**. Das
ist so gewollt (Events werden datiert statt bewertet), aber es ist ein
Datenverlust und nicht zurückzunehmen.
Ebenfalls neu: Fahrer anlegen, ändern und löschen darf ab dann nur noch die
Teamleitung. Mitglieder ohne Admin-Rolle verlieren diese Möglichkeit auch auf
der Event-Seite.
### Typen einmal neu erzeugen
`frontend/src/lib/types.d.ts` wurde bei den letzten Schema-Änderungen von Hand
ergänzt (`riders.user`, `trails.status_by`/`status_changed`,
`events.starts`/`ends` statt `status`). Grund: `pocketbase-typegen` braucht
native sqlite3-Bindings, die in der Arbeitsumgebung nicht gebaut werden durften.
```bash
cd frontend && npm run generate-pocketbase-types
```
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
des Generators.
## Kleinigkeiten
- Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die
gelockerte `users.viewRule` kommt mit `1754500500`.
- Die Rolle **Superadmin** existiert seit `1754500900` und ist für
stammtisch@dne.name gesetzt, gibt in der App aber noch keine besonderen
Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die `updateRule` der
users-Collection schließt `superadmin` vom Selbstsetzen aus. Sobald klar ist,
was sie dürfen soll, kommen die Regeln dazu.
- Flag-Typen bleiben **teamspezifisch**. Ein neues Team bekommt beim Anlegen
den hartkodierten Standardsatz aus `trailFlags.svelte.ts`, den es danach frei
ändern kann. Eine appweite Verwaltung gibt es damit bewusst nicht.
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.

View file

@ -12,9 +12,9 @@ migrate((app) => {
{
"id": "_pb_users_auth_",
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": null,
"updateRule": "id = @request.auth.id",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false",
"deleteRule": "id = @request.auth.id",
"name": "users",
"type": "auth",
@ -125,6 +125,15 @@ migrate((app) => {
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "bool_user_superadmin",
"name": "superadmin",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
@ -457,20 +466,26 @@ migrate((app) => {
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "select2063623452",
"maxSelect": 1,
"name": "status",
"id": "date_event_starts",
"max": "",
"min": "",
"name": "starts",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"draft",
"active",
"finished"
]
"type": "date"
},
{
"hidden": false,
"id": "date_event_ends",
"max": "",
"min": "",
"name": "ends",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"cascadeDelete": false,
@ -590,9 +605,9 @@ migrate((app) => {
"id": "spgs7e4anufen9u",
"listRule": "team.users.id ?= @request.auth.id",
"viewRule": "team.users.id ?= @request.auth.id",
"createRule": "team.users.id ?= @request.auth.id",
"updateRule": "team.users.id ?= @request.auth.id",
"deleteRule": "team.users.id ?= @request.auth.id",
"createRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
"updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
"name": "riders",
"type": "base",
"fields": [
@ -655,6 +670,20 @@ migrate((app) => {
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_rider_user",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "autodate2990389176",
@ -1013,6 +1042,31 @@ migrate((app) => {
"gesperrt"
]
},
{
"hidden": false,
"id": "date_trail_status_changed",
"max": "",
"min": "",
"name": "status_changed",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_trail_status_by",
"maxSelect": 1,
"minSelect": 0,
"name": "status_by",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
@ -1467,10 +1521,40 @@ migrate((app) => {
"minSelect": 0,
"name": "flag",
"presentable": false,
"required": true,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_marker_label",
"max": 120,
"min": 0,
"name": "label",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_marker_icon",
"max": 60,
"min": 0,
"name": "icon",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,

View file

@ -0,0 +1,73 @@
/// <reference path="../pb_data/types.d.ts" />
// Fahrer bekommen ein optionales Benutzerkonto, und die Fahrerverwaltung wird
// zur Sache der Teamleitung.
//
// 1) riders.user — Verknüpfung eines Fahrers mit einem Login. Optional, weil
// die meisten Fahrer nur in Ergebnislisten auftauchen und sich nie
// anmelden. cascadeDelete ist bewusst aus: Wird ein Konto gelöscht, soll
// der Fahrer samt seiner Zeiten erhalten bleiben.
//
// 2) riders create/update/delete nur noch für Owner und Admins. Bisher durfte
// jedes Teammitglied Fahrer anlegen, ändern und löschen — mit den Zeiten
// daran ist das zu viel Vertrauen in jeden Mitfahrer. Lesen bleibt für das
// ganze Team offen.
//
// 3) users.viewRule von "nur ich selbst" auf "eingeloggt" gelockert. Ohne das
// lässt sich zu einer bekannten User-ID kein Name anzeigen — betrifft die
// Mitgliederliste eines Teams, den Melder eines Trail-Markers und die
// Kontoverknüpfung eines Fahrers. Die listRule bleibt eng: Wer keine ID
// kennt, kann auch keine Konten durchblättern. E-Mail-Adressen bleiben
// über emailVisibility verborgen.
//
// 4) users.createRule von "niemand" auf "eingeloggt". Damit kann ein Admin
// einem Fahrer direkt ein Login anlegen. Bewusst nicht offen (""), sonst
// wäre es eine öffentliche Registrierung.
const RIDERS_ADMIN_RULE =
'team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id'
const RIDERS_MEMBER_RULE = 'team.users.id ?= @request.auth.id'
const LOGGED_IN = '@request.auth.id != ""'
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
const riders = app.findCollectionByNameOrId('riders')
// Der Snapshot in 1754400000_init_schema.js führt das Feld inzwischen
// mit. Auf einer frischen Datenbank ist es also schon da, auf einer
// bestehenden noch nicht — dieselbe Lage wie bei den trail-Collections.
if (!riders.fields.getByName('user')) {
riders.fields.add(new Field({
id: 'relation_rider_user',
name: 'user',
type: 'relation',
collectionId: users.id,
cascadeDelete: false,
maxSelect: 1,
minSelect: 0,
required: false,
}))
}
riders.createRule = RIDERS_ADMIN_RULE
riders.updateRule = RIDERS_ADMIN_RULE
riders.deleteRule = RIDERS_ADMIN_RULE
app.save(riders)
users.viewRule = LOGGED_IN
users.createRule = LOGGED_IN
app.save(users)
}, (app) => {
const users = app.findCollectionByNameOrId('users')
const riders = app.findCollectionByNameOrId('riders')
riders.fields.removeByName('user')
riders.createRule = RIDERS_MEMBER_RULE
riders.updateRule = RIDERS_MEMBER_RULE
riders.deleteRule = RIDERS_MEMBER_RULE
app.save(riders)
users.viewRule = 'id = @request.auth.id'
users.createRule = null
app.save(users)
})

View file

@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
// Events werden datiert statt bewertet.
//
// Der Status (draft/active/finished) war Handarbeit: Jemand musste ein Event
// aktiv schalten und hinterher wieder abschließen, sonst stand die Übersicht
// falsch da. Ein Datum weiß von selbst, was ansteht, was läuft und was vorbei
// ist.
//
// starts trägt den Tag; ends bleibt leer, wenn das Event nur einen Tag dauert,
// und trägt sonst den letzten Tag. Beide sind optional, damit bestehende
// Events ohne Datum weiterleben, bis jemand eines nachträgt.
migrate((app) => {
const events = app.findCollectionByNameOrId('events')
// Auf einer frischen Datenbank hat der Snapshot die Felder bereits.
if (!events.fields.getByName('starts')) {
events.fields.add(new Field({
id: 'date_event_starts',
name: 'starts',
type: 'date',
}))
}
if (!events.fields.getByName('ends')) {
events.fields.add(new Field({
id: 'date_event_ends',
name: 'ends',
type: 'date',
}))
}
if (events.fields.getByName('status')) {
events.fields.removeByName('status')
}
app.save(events)
}, (app) => {
const events = app.findCollectionByNameOrId('events')
events.fields.removeByName('starts')
events.fields.removeByName('ends')
events.fields.add(new Field({
id: 'select_event_status',
name: 'status',
type: 'select',
maxSelect: 1,
values: ['draft', 'active', 'finished'],
}))
app.save(events)
})

View file

@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
// Wer hat den Trail gesperrt, und wann?
//
// Der Status allein sagt nur, wie es gerade steht. Für eine Meldung, der man
// trauen soll, gehört dazu, wann sie kam und von wem — eine Sperrung von
// heute Morgen wiegt anders als eine vom letzten Herbst.
//
// Beide Felder schreibt die Anwendung beim Setzen des Status mit; bestehende
// Trails bleiben ohne Angabe, bis der Status das nächste Mal geändert wird.
migrate((app) => {
const trails = app.findCollectionByNameOrId('trails')
const users = app.findCollectionByNameOrId('users')
if (!trails.fields.getByName('status_changed')) {
trails.fields.add(new Field({
id: 'date_trail_status_changed',
name: 'status_changed',
type: 'date',
}))
}
if (!trails.fields.getByName('status_by')) {
trails.fields.add(new Field({
id: 'relation_trail_status_by',
name: 'status_by',
type: 'relation',
collectionId: users.id,
cascadeDelete: false,
maxSelect: 1,
minSelect: 0,
required: false,
}))
}
app.save(trails)
}, (app) => {
const trails = app.findCollectionByNameOrId('trails')
trails.fields.removeByName('status_changed')
trails.fields.removeByName('status_by')
app.save(trails)
})

View file

@ -0,0 +1,57 @@
/// <reference path="../pb_data/types.d.ts" />
// Marker dürfen einen eigenen Text und ein eigenes Icon tragen.
//
// Bisher musste jede Meldung auf einen vorgefertigten Flag-Typ zeigen. Auf
// dem Trail passt aber nicht immer einer: „Wespennest am Anlieger" legt
// niemand vorher als Typ an. Deshalb:
//
// - flag ist nicht mehr Pflicht. Fehlt es, trägt der Marker seinen eigenen
// Text in label.
// - icon steht am Marker selbst. Beim Wählen eines Typs kommt dessen Icon als
// Vorschlag; ändern lässt es sich trotzdem, denn dieselbe Art Hindernis
// sieht nicht immer gleich aus.
//
// Bestehende Marker bleiben unverändert gültig: Sie haben ein flag und weder
// label noch icon, die Anzeige fällt dann auf den Typ zurück.
migrate((app) => {
const markers = app.findCollectionByNameOrId('trail_markers')
const flag = markers.fields.getByName('flag')
if (flag) {
flag.required = false
}
if (!markers.fields.getByName('label')) {
markers.fields.add(new Field({
id: 'text_marker_label',
name: 'label',
type: 'text',
max: 120,
}))
}
if (!markers.fields.getByName('icon')) {
markers.fields.add(new Field({
id: 'text_marker_icon',
name: 'icon',
type: 'text',
max: 60,
}))
}
app.save(markers)
}, (app) => {
const markers = app.findCollectionByNameOrId('trail_markers')
const flag = markers.fields.getByName('flag')
if (flag) {
flag.required = true
}
markers.fields.removeByName('label')
markers.fields.removeByName('icon')
app.save(markers)
})

View file

@ -0,0 +1,47 @@
/// <reference path="../pb_data/types.d.ts" />
// Superadmin als appweite Rolle, über allen Teams.
//
// Wichtig ist die Ergänzung der updateRule: Jeder darf seinen eigenen
// Datensatz ändern — ohne `@request.body.superadmin:isset = false` könnte
// sich damit jeder selbst befördern. Vergeben wird die Rolle deshalb nur
// direkt in PocketBase oder durch eine Migration wie diese.
//
// Der erste Superadmin ist stammtisch@dne.name. Existiert das Konto (noch)
// nicht, passiert hier nichts weiter — die Rolle lässt sich jederzeit im
// Admin-UI nachziehen.
//
// Flag-Typen bleiben ausdrücklich Sache der Teams: Jedes Team verwaltet
// seine eigenen und bekommt beim Anlegen den Standardsatz aus der App.
// Superadmin hat damit im Alltag nichts zu tun.
const FIRST_SUPERADMIN = 'stammtisch@dne.name'
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
if (!users.fields.getByName('superadmin')) {
users.fields.add(new Field({
id: 'bool_user_superadmin',
name: 'superadmin',
type: 'bool',
}))
}
users.updateRule = 'id = @request.auth.id && @request.body.superadmin:isset = false'
app.save(users)
try {
const first = app.findAuthRecordByEmail('users', FIRST_SUPERADMIN)
first.set('superadmin', true)
app.save(first)
} catch {
// Konto noch nicht angelegt — die Rolle wird dann von Hand vergeben.
}
}, (app) => {
const users = app.findCollectionByNameOrId('users')
users.fields.removeByName('superadmin')
users.updateRule = 'id = @request.auth.id'
app.save(users)
})

View file

@ -0,0 +1,78 @@
<script lang="ts">
/**
* Bild eines Nutzers — und wenn keins hinterlegt ist, ein erzeugtes aus
* Initialen und einer Farbe.
*
* Die Farbe kommt aus einer Prüfsumme über die ID: Derselbe Mensch bekommt
* damit überall dasselbe Bild, ohne dass es irgendwo gespeichert werden
* müsste. Bewusst kein externer Avatar-Dienst — der würde bei jedem
* Seitenaufruf verraten, wer hier gerade unterwegs ist.
*/
import { getFileURL } from '$lib/stores/pocketbase.svelte'
let {
user = null,
size = 32,
class: className = '',
}: {
user?: { id?: string; name?: string; email?: string; avatar?: string } | null
size?: number
class?: string
} = $props()
const label = $derived(user?.name?.trim() || user?.email?.trim() || '')
/** Erste Buchstaben der ersten beiden Wörter, sonst der erste des Namens. */
const initials = $derived.by(() => {
if (!label) return '?'
const words = label.split(/[\s.@_-]+/).filter(Boolean)
if (words.length === 0) return '?'
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
})
/**
* Farbton aus der ID, hilfsweise aus dem Namen. Sättigung und Helligkeit
* bleiben fest, damit weiße Schrift auf jedem Ton lesbar bleibt — auch im
* Dunkelmodus, wo eine zufällige Helligkeit schnell blendet.
*/
const hue = $derived.by(() => {
const seed = user?.id || label || '?'
let h = 0
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) % 360
}
return h
})
const src = $derived(
user?.avatar
? getFileURL(user as object, user.avatar, { thumb: `${size * 2}x${size * 2}` })
: null,
)
</script>
{#if src}
<img
{src}
alt={label}
width={size}
height={size}
class="shrink-0 rounded-full object-cover {className}"
style:width="{size}px"
style:height="{size}px"
/>
{:else}
<span
aria-hidden="true"
class="grid shrink-0 place-items-center rounded-full font-medium text-white select-none {className}"
style:width="{size}px"
style:height="{size}px"
style:background="hsl({hue} 55% 42%)"
style:font-size="{Math.round(size * 0.4)}px"
>
{initials}
</span>
{/if}

View file

@ -1,17 +1,28 @@
<script lang="ts">
/**
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente die
* Distanz des Punktes; die Elternkomponente reicht sie an die Karte
* weiter, damit dort dieselbe Stelle hervorgehoben wird. Distanz statt
* Index, weil die Karte eine andere (vereinfachte) Punktreihe zeigt.
* Höhenprofil als SVG-Fläche. Die hervorgehobene Stelle kommt als Prop
* herein und geht beim Überfahren als Callback hinaus — die Elternseite
* hält den Wert und reicht ihn ebenso an die Karte weiter. So zeigen
* Karte und Profil immer dieselbe Stelle, egal wo gerade gezeigt wird.
* Gekoppelt wird über die Distanz, nicht über den Index: Die Karte zeigt
* eine vereinfachte Punktreihe, das Profil alle Originalpunkte.
*/
let {
elevation,
onhover,
onselect,
highlightDistance = null,
height = '120px',
}: {
elevation: { d: number; ele: number }[]
onhover?: (distance: number | null) => void
/**
* Klick auf eine Stelle des Profils. Damit lässt sich ein Marker auch
* über die Höhe finden — im steilen Stück, statt die Linie auf der
* Karte abzusuchen.
*/
onselect?: (distance: number) => void
highlightDistance?: number | null
height?: string
} = $props()
@ -46,42 +57,148 @@
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
})
let hoverX = $state<number | null>(null)
/**
* Der Punkt zur hervorgehobenen Distanz. Gesucht wird binär, weil das
* Profil alle Originalpunkte enthält — bei jeder Mausbewegung linear
* durch mehrere tausend Einträge zu laufen wäre unnötig teuer.
*/
const hover = $derived.by(() => {
if (!stats || highlightDistance == null) return null
function handleMove(e: MouseEvent) {
if (!elevation.length) return
let lo = 0
let hi = elevation.length - 1
while (hi - lo > 1) {
const mid = (lo + hi) >> 1
if (elevation[mid].d <= highlightDistance) lo = mid
else hi = mid
}
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
const p =
Math.abs(elevation[lo].d - highlightDistance) <=
Math.abs(elevation[hi].d - highlightDistance)
? elevation[lo]
: elevation[hi]
return {
ele: p.ele,
d: p.d,
// In Prozent, damit die HTML-Overlays dieselbe Stelle treffen wie
// das über viewBox skalierte SVG.
left: (p.d / stats.totalD) * 100,
top: (1 - (p.ele - stats.min) / stats.span) * 100,
}
})
/** Distanz an der Mausposition; null, wenn es keine Punkte gibt. */
function distanceAt(e: MouseEvent): number | null {
if (!elevation.length) return null
const rect = (e.currentTarget as HTMLElement).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?.(elevation[clamped].d)
return elevation[clamped].d
}
function handleMove(e: MouseEvent) {
const d = distanceAt(e)
if (d !== null) onhover?.(d)
}
function handleClick(e: MouseEvent) {
if (!onselect) return
const d = distanceAt(e)
if (d !== null) onselect(d)
}
function handleLeave() {
hoverX = null
onhover?.(null)
}
/**
* Tastaturweg zum Setzen: Mit den Pfeiltasten die Stelle wählen, mit
* Enter oder Leertaste bestätigen. Ohne das wäre das Profil ein reines
* Mausziel.
*/
function handleKey(e: KeyboardEvent) {
if (!onselect || !stats) return
const total = stats.totalD
const step = total / 50
const current = highlightDistance ?? 0
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault()
const next = e.key === 'ArrowRight' ? current + step : current - step
onhover?.(Math.max(0, Math.min(total, next)))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (highlightDistance != null) onselect(highlightDistance)
}
}
</script>
{#if stats}
<div class="w-full" style:height>
<div class="relative w-full" style:height>
<!--
Die Fläche ist ein echter Knopf: Sie reagiert auf Zeiger und
Tastatur, und ein <svg> mit Rollen und tabindex bleibt für
Hilfsmittel ein Bild mit angeklebtem Verhalten.
-->
<button
type="button"
class="block w-full h-full border-0 bg-transparent p-0"
class:cursor-pointer={!!onselect}
aria-label={onselect
? 'Höhenprofil hier klicken setzt einen Marker an dieser Stelle'
: 'Höhenprofil'}
onmousemove={handleMove}
onmouseleave={handleLeave}
onclick={handleClick}
onkeydown={handleKey}
>
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="w-full h-full"
role="img"
aria-label="Höhenprofil"
onmousemove={handleMove}
onmouseleave={handleLeave}
aria-hidden="true"
>
<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 hover}
<line
x1={(hover.left / 100) * W}
y1="0"
x2={(hover.left / 100) * W}
y2={H}
stroke="rgb(14 165 233)"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
</button>
{#if hover}
<!--
Punkt und Beschriftung liegen als HTML über dem SVG: dessen
viewBox wird ungleichmäßig gestreckt, ein Kreis darin würde
zur Ellipse und Text mitverzerrt.
-->
<span
class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500 ring-2 ring-background"
style:left="{hover.left}%"
style:top="{hover.top}%"
></span>
<span
class="pointer-events-none absolute top-0 -translate-x-1/2 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium whitespace-nowrap shadow-sm"
style:left="clamp(2.5rem, {hover.left}%, calc(100% - 2.5rem))"
>
{Math.round(hover.ele)} m · {(hover.d / 1000).toFixed(2)} km
</span>
{/if}
</div>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{Math.round(stats.min)} m</span>

View file

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Rendert das Icon eines Flag-Typs. Der Name kommt aus der Datenbank und
* ist Freitext — solange geladen wird und bei unbekannten Namen steht
* ersatzweise die Flagge da, statt eine Lücke zu lassen.
*/
import { Flag } from 'lucide-svelte'
import { iconLoader } from '$lib/flagIcons'
let {
name,
color,
class: className = 'size-4',
}: { name?: string; color?: string; class?: string } = $props()
// Das Versprechen einmal je Name bilden, nicht bei jedem Rendern neu.
const loading = $derived(iconLoader(name)?.() ?? null)
</script>
<!-- Farbe über den Wrapper: Lucide zeichnet mit currentColor. -->
<span class="inline-flex shrink-0" style:color>
{#if loading}
{#await loading}
<Flag class={className} />
{:then mod}
{@const Icon = mod.default as typeof Flag}
<Icon class={className} />
{:catch}
<Flag class={className} />
{/await}
{:else}
<Flag class={className} />
{/if}
</span>

View file

@ -0,0 +1,64 @@
<script lang="ts">
/**
* Icon-Auswahl: Vorschau, Freitextfeld und ein Raster mit Vorschlägen.
* Erlaubt ist jeder Name von lucide.dev — die Vorschläge sind nur die
* Abkürzung fürs häufig Gebrauchte.
*
* Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit
* beide dieselbe Auswahl zeigen.
*/
import FlagIcon from './FlagIcon.svelte'
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
let {
value = $bindable(''),
color = undefined,
id = 'icon-picker',
label = 'Icon',
}: {
value?: string
/** Färbt Vorschau und Vorschläge, damit man das Ergebnis sieht. */
color?: string
id?: string
label?: string
} = $props()
</script>
<div class="space-y-2">
<Label for={id}>{label}</Label>
<div class="flex items-center gap-2">
<span class="grid size-9 shrink-0 place-items-center rounded-md border">
<FlagIcon name={value} {color} class="size-5" />
</span>
<Input {id} bind:value placeholder="z. B. TreePine" class="flex-1" />
</div>
<p class="text-xs text-muted-foreground">
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
{#if value.trim() && !iconExists(value)}
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
{/if}
</p>
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
{#each FLAG_ICON_SUGGESTIONS as name (name)}
<button
type="button"
title={name}
aria-label={name}
aria-pressed={value === name}
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
class:bg-accent={value === name}
class:ring-2={value === name}
class:ring-primary={value === name}
onclick={() => (value = name)}
>
<FlagIcon {name} {color} class="size-4" />
</button>
{/each}
</div>
</div>

View file

@ -0,0 +1,149 @@
<script lang="ts">
/**
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Run-Seite.
*
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
*
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
* Zeit-Dialog mit Fahrer und Run vorbelegt.
*/
import { getTimeContext } from '$lib/stores/times.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import TimeDialog from './TimeDialog.svelte'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, Timer } from 'lucide-svelte'
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const clock = ticker()
const running = $derived(times.allRunning(clock.now))
const single = $derived(running.length === 1)
let expanded = $state(false)
// Vorbelegung für den Zeit-Dialog
let timeDialog = $state(false)
let dialogEvent = $state('')
let dialogRider = $state<string | null>(null)
let dialogRun = $state<string | null>(null)
function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
if (!run?.event) return
dialogEvent = run.event
dialogRider = riderId ?? null
dialogRun = run.id
timeDialog = true
}
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
const open = $derived(single || expanded)
function riderLabel(riderId: string | undefined) {
const r = riderId ? riders.getById(riderId) : undefined
if (!r) return 'Unbekannter Fahrer'
return `#${r.number || '—'} ${fullName(r)}`.trim()
}
function runPath(runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
if (!run?.event) return null
return `/dashboard/events/${run.event}/runs/${run.id}`
}
function runLabel(runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
return run?.description || 'Run'
}
</script>
{#if running.length > 0}
<!-- fixed statt sticky: Die Leiste soll auch beim Scrollen stehen bleiben. -->
<div class="fixed bottom-4 right-4 z-50 w-[min(24rem,calc(100vw-2rem))]">
<div class="rounded-lg border bg-card text-card-foreground shadow-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 border-b bg-primary/5">
<span class="relative flex size-2 shrink-0">
<span class="absolute inline-flex h-full w-full rounded-full bg-primary opacity-60 animate-ping"></span>
<span class="relative inline-flex size-2 rounded-full bg-primary"></span>
</span>
<span class="text-sm font-medium">
{running.length === 1 ? 'Zeit läuft' : `${running.length} Zeiten laufen`}
</span>
{#if !open}
<span class="text-sm tabular-nums font-semibold ml-auto">
{running[0].formattedTime}
</span>
{/if}
{#if !single}
<Button
variant="ghost"
size="icon"
class="ml-auto size-7 shrink-0"
title={expanded ? 'Einklappen' : 'Alle anzeigen'}
onclick={() => (expanded = !expanded)}
>
{#if expanded}
<ChevronDown class="size-4" />
{:else}
<ChevronUp class="size-4" />
{/if}
</Button>
{/if}
</div>
{#if open}
<ul class="divide-y max-h-[50vh] overflow-y-auto">
{#each running as t (t.id)}
{@const path = runPath(t.run)}
<li class="flex items-center gap-2 px-3 py-2">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{riderLabel(t.rider)}</p>
<p class="text-xs text-muted-foreground truncate">
{#if path}
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
{:else}
{runLabel(t.run)}
{/if}
</p>
</div>
<span class="text-lg font-semibold tabular-nums shrink-0">
{t.formattedTime}
</span>
<Button
variant="ghost"
size="icon"
class="shrink-0"
title="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.run)}
>
<Timer class="size-4" />
</Button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<TimeDialog
bind:open={timeDialog}
eventId={dialogEvent}
riderId={dialogRider}
runId={dialogRun}
/>
{/if}

View file

@ -35,7 +35,7 @@
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto('/dashboard/teams')}>
<DropdownMenu.Item onclick={() => goto('/dashboard/settings/team')}>
<Users class="h-4 w-4 mr-2" />
Teams verwalten
</DropdownMenu.Item>

View file

@ -0,0 +1,219 @@
<script lang="ts">
/**
* Der eine Dialog für die Zeitnahme. Fahrer und Run stehen immer beide
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
*/
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select'
import { AlertTriangle, Play, Square } from 'lucide-svelte'
let {
open = $bindable(false),
eventId,
riderId = null,
runId = null,
}: {
open?: boolean
/** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
eventId: string
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
riderId?: string | null
runId?: string | null
} = $props()
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const clock = ticker()
let selectedRider = $state('')
let selectedRun = $state('')
let error = $state<string | null>(null)
let confirmDnf = $state(false)
let busy = $state(false)
// Beim Öffnen auf die Vorbelegung zurücksetzen — ein Dialog, der noch die
// Auswahl vom letzten Mal zeigt, ist an der Strecke eine Fehlerquelle.
$effect(() => {
if (!open) return
selectedRider = riderId ?? ''
selectedRun = runId ?? ''
error = null
confirmDnf = false
})
const eventRiders = $derived(eventId ? riders.getByEvent(eventId) : [])
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
const riderLabel = $derived(
selectedRider
? (() => {
const r = riders.getById(selectedRider)
return r ? `#${r.number || '—'} ${fullName(r)}`.trim() : 'Fahrer wählen…'
})()
: 'Fahrer wählen…',
)
const runLabel = $derived(
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
)
const ready = $derived(!!selectedRider && !!selectedRun)
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
const active = $derived(
ready
? times
.allRunning(clock.now)
.find((t) => t.run === selectedRun && t.rider === selectedRider)
: undefined,
)
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
const existing = $derived(
ready && !active
? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
: undefined,
)
async function start() {
if (!ready || busy) return
busy = true
error = null
try {
await times.start(selectedRun, selectedRider)
open = false
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
} finally {
busy = false
}
}
async function stop(status: 'finished' | 'dnf') {
if (!active || busy) return
busy = true
error = null
try {
await times.stop(active.id, status as TimeStatus)
open = false
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestoppt werden.'
} finally {
busy = false
confirmDnf = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Zeit erfassen</Dialog.Title>
<Dialog.Description>
Fahrer und Run wählen — je nach Stand wird gestartet oder gestoppt.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label>Fahrer</Label>
<Select.Root type="single" bind:value={selectedRider} disabled={eventRiders.length === 0}>
<Select.Trigger class="w-full">{riderLabel}</Select.Trigger>
<Select.Content>
{#each eventRiders as r (r.id)}
<Select.Item value={r.id}>
#{r.number || '—'} {fullName(r)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if eventRiders.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Fahrer.</p>
{/if}
</div>
<div class="space-y-2">
<Label>Run</Label>
<Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
<Select.Trigger class="w-full">{runLabel}</Select.Trigger>
<Select.Content>
{#each eventRuns as r (r.id)}
<Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if eventRuns.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
{/if}
</div>
{#if active}
<div class="rounded-lg border bg-primary/5 py-4 text-center">
<p class="text-xs text-muted-foreground">läuft seit</p>
<p class="font-mono text-4xl font-semibold tabular-nums">{active.formattedTime}</p>
</div>
{:else if existing}
<p class="text-sm text-muted-foreground">
Für diese Paarung gibt es bereits eine Zeit ({existing.formattedTime}).
Ein neuer Start legt eine zweite an.
</p>
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
{#if confirmDnf}
<!-- DNF beendet die Zeit ohne Wertung und ist nicht zurückzunehmen. -->
<div class="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-3">
<p class="text-sm flex items-start gap-2">
<AlertTriangle class="size-4 text-destructive shrink-0 mt-0.5" />
<span>
Wirklich als DNF werten? Die Zeit wird beendet und taucht in keiner
Rangliste auf.
</span>
</p>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="sm" onclick={() => (confirmDnf = false)}>
Zurück
</Button>
<Button variant="destructive" size="sm" disabled={busy} onclick={() => stop('dnf')}>
DNF bestätigen
</Button>
</div>
</div>
{:else}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
{#if active}
<Button variant="outline" disabled={busy} onclick={() => (confirmDnf = true)}>
DNF
</Button>
<Button variant="destructive" disabled={busy} onclick={() => stop('finished')}>
<Square class="h-4 w-4 mr-2" />
Stoppen
</Button>
{:else}
<Button disabled={!ready || busy} onclick={start}>
<Play class="h-4 w-4 mr-2" />
Starten
</Button>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>

View file

@ -4,9 +4,9 @@
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
*/
import { onMount } from 'svelte'
import { mount, onMount, unmount } from 'svelte'
import * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker } from 'maplibre-gl'
import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
// MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und
// lädt den Worker zur Laufzeit relativ zum eigenen Verzeichnis nach. Beides
@ -22,6 +22,11 @@
// Datei — ein blosses ?url würde nur die Rohdatei kopieren und ihren
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
import FlagIcon from './FlagIcon.svelte'
import { trailStatusColor } from '$lib/trailStatus'
import { env } from '$env/dynamic/public'
import { mode } from 'mode-watcher'
maplibregl.setWorkerUrl(workerUrl)
@ -32,7 +37,15 @@
lng: number
color: string
resolved: boolean
/** Was — der Flag-Typ */
label: string
/** Icon-Name des Flag-Typs */
icon?: string
/** Wann — bereits formatiert */
when?: string
/** Wer — bereits aufgelöst */
who?: string
note?: string
}
let {
@ -40,44 +53,89 @@
bounds = null,
markers = [],
status = 'offen',
placing = false,
coordDistances = [],
highlightDistance = null,
onplace,
onmarkerclick,
ontrailhover,
controls = true,
height = '420px',
}: {
geojson?: LineGeoJSON | null
bounds?: [[number, number], [number, number]] | null
markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
placing?: boolean
coordDistances?: number[]
highlightDistance?: number | null
onplace?: (coords: { lat: number; lng: number }) => void
onmarkerclick?: (id: string) => void
/**
* Wird nur beim Klick auf die Trail-Linie gerufen — und zwar mit der
* darauf projizierten Stelle, nie mit dem rohen Klickpunkt. Ist der
* Callback nicht gesetzt, ist die Linie nicht klickbar.
*/
onplace?: (p: { lat: number; lng: number; distance: number | null }) => void
/** Streckenkilometer unter dem Zeiger, null beim Verlassen der Linie. */
ontrailhover?: (distance: number | null) => void
/**
* Zoom-Knöpfe, Maßstab und Quellenangabe. In der Kachelvorschau
* abgeschaltet — dort ist die Karte Bild, nicht Werkzeug. Die
* Quellenangabe zu OpenStreetMap trägt die große Karte.
*/
controls?: boolean
height?: string
} = $props()
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine
// Outdoor-Karte mit Höhenlinien tauschen lässt.
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende'
/**
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
* dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten
* dunklen Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein
* dunkleres Bild, keine dunkle Karte.
*
* Mehrere Subdomains, weil Browser die Verbindungen pro Host begrenzen.
*/
// Der Schlüssel muss den Browser erreichen — er kommt deshalb aus den
// PUBLIC_-Variablen. Über $env/dynamic/public statt static, weil die
// Kacheln auch ohne Schlüssel ausgeliefert werden: Fehlt die Variable,
// soll der Build nicht abbrechen.
const CARTO_KEY = env.PUBLIC_CARTO_API_KEY ?? ''
const KEY_PARAM = CARTO_KEY ? `?key=${CARTO_KEY}` : ''
const cartoTiles = (style: 'light_all' | 'dark_all') =>
['a', 'b', 'c'].map(
(sub) => `https://${sub}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png${KEY_PARAM}`,
)
const TILES_LIGHT = cartoTiles('light_all')
const TILES_DARK = cartoTiles('dark_all')
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende, &copy; CARTO'
const isDark = $derived(mode.current === 'dark')
// 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
let popup: Popup | null = null
/**
* MapLibre will für einen Marker ein DOM-Element, Icons kommen aber als
* Svelte-Komponenten. `mount` schlägt die Brücke — dafür müssen die
* Instanzen von Hand wieder abgeräumt werden, sonst bleiben sie beim
* Neuaufbau der Marker hängen.
*/
let iconInstances: Record<string, unknown>[] = []
function mountIcon(target: HTMLElement, name: string | undefined, size: string) {
iconInstances.push(mount(FlagIcon, { target, props: { name, class: size } }))
}
function clearIcons() {
for (const inst of iconInstances) unmount(inst)
iconInstances = []
}
onMount(() => {
let cleanupMap: (() => void) | null = null
@ -90,7 +148,7 @@
sources: {
osm: {
type: 'raster',
tiles: [TILES],
tiles: isDark ? TILES_DARK : TILES_LIGHT,
tileSize: 256,
attribution: ATTRIBUTION,
},
@ -99,20 +157,19 @@
},
center: FALLBACK_CENTER,
zoom: 11,
attributionControl: controls ? undefined : false,
})
map = instance
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
if (controls) {
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
}
instance.on('load', () => {
ready = true
})
instance.on('click', (e) => {
if (placing) onplace?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
})
cleanupMap = () => {
// Map.remove() räumt Controls auf, aber keine Marker — die tragen
// eigene move-Listener auf der Karte und werden hier zuerst entfernt.
@ -120,6 +177,9 @@
markerObjects = []
highlightMarker?.remove()
highlightMarker = null
popup?.remove()
popup = null
clearIcons()
instance.remove()
map = null
@ -146,12 +206,58 @@
}
})
/**
* Hover und Klick hängen an einer unsichtbaren, breiten Kopie der Linie:
* die sichtbaren 4 px wären als Ziel zu schmal, und maplibre liefert
* layerbezogene Ereignisse nur für das, was wirklich getroffen wurde.
* Deshalb landet ein Klick neben dem Trail auch nirgends — Marker können
* gar nicht abseits der Strecke entstehen.
*
* Registriert wird einmalig direkt nach dem Anlegen der Layer; vorher
* hätten die layerbezogenen Listener kein Ziel.
*/
function bindTrailEvents(instance: MapLibreMap) {
const project = (e: maplibregl.MapMouseEvent) => {
const coords = geojson?.coordinates
if (!coords?.length) return null
const pos = nearestOnLine(coords, e.lngLat.lng, e.lngLat.lat)
if (!pos) return null
return { pos, distance: distanceAlong(pos, coordDistances) }
}
instance.on('mousemove', 'trail-hit', (e) => {
const hit = project(e)
if (!hit) return
instance.getCanvas().style.cursor = onplace ? 'pointer' : ''
ontrailhover?.(hit.distance)
})
instance.on('mouseleave', 'trail-hit', () => {
instance.getCanvas().style.cursor = ''
ontrailhover?.(null)
})
instance.on('click', 'trail-hit', (e) => {
if (!onplace) return
const hit = project(e)
if (!hit) return
// Ein offenes Marker-Popup gehört zur vorherigen Stelle.
popup?.remove()
onplace({ lat: hit.pos.lat, lng: hit.pos.lng, distance: hit.distance })
})
}
// Trail-Linie zeichnen bzw. aktualisieren
$effect(() => {
if (!map || !ready) return
const data = geojson
const color = LINE_COLORS[status] ?? LINE_COLORS.offen
const color = trailStatusColor(status)
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) {
@ -175,22 +281,83 @@
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 },
})
map.addLayer({
id: 'trail-hit',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
})
bindTrailEvents(map)
}
})
/** Beim Themenwechsel die Kachelquelle austauschen. */
$effect(() => {
if (!map || !ready) return
const src = map.getSource('osm') as maplibregl.RasterTileSource | undefined
src?.setTiles(isDark ? TILES_DARK : TILES_LIGHT)
})
// Kartenausschnitt auf den Track setzen
$effect(() => {
if (!map || !ready || !bounds) return
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
})
/** Popup-Inhalt zu einem Marker: was, wann, wer — plus Notiz. */
function popupContent(data: MarkerData): HTMLElement {
const root = document.createElement('div')
root.className = 'trail-popup'
const head = document.createElement('div')
head.className = 'trail-popup-head'
const icon = document.createElement('span')
icon.className = 'trail-popup-icon'
icon.style.color = data.color
mountIcon(icon, data.icon, 'size-4')
head.appendChild(icon)
const title = document.createElement('strong')
// textContent, nicht innerHTML: Label und Notiz sind Nutzereingaben.
title.textContent = data.label
head.appendChild(title)
if (data.resolved) {
const badge = document.createElement('span')
badge.className = 'trail-popup-badge'
badge.textContent = 'erledigt'
head.appendChild(badge)
}
root.appendChild(head)
if (data.note) {
const note = document.createElement('p')
note.className = 'trail-popup-note'
note.textContent = data.note
root.appendChild(note)
}
const meta = document.createElement('p')
meta.className = 'trail-popup-meta'
meta.textContent = [data.when, data.who].filter(Boolean).join(' · ')
if (meta.textContent) root.appendChild(meta)
return root
}
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und
// zuverlässiger als einzelne Aktualisierungen.
$effect(() => {
if (!map || !ready) return
const instance = map
for (const m of markerObjects) m.remove()
markerObjects = []
clearIcons()
for (const data of markers) {
const el = document.createElement('button')
@ -198,15 +365,28 @@
el.title = data.label
el.setAttribute('aria-label', data.label)
el.style.cssText = `
width: 18px; height: 18px; border-radius: 9999px;
width: 26px; height: 26px; border-radius: 9999px;
border: 2px solid white; cursor: pointer; padding: 0;
display: grid; place-items: center; color: white;
background: ${data.color};
opacity: ${data.resolved ? '0.35' : '1'};
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
mountIcon(el, data.icon, 'size-3.5')
// Der Klick bleibt beim Marker: Er darf kein neues Marker-Fenster
// öffnen, sondern zeigt nur, was hier schon gemeldet wurde.
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onmarkerclick?.(data.id)
popup?.remove()
popup = new maplibregl.Popup({
offset: 14,
closeButton: false,
className: 'trail-popup-shell',
})
.setLngLat([data.lng, data.lat])
.setDOMContent(popupContent(data))
.addTo(instance)
})
el.addEventListener('focus', () => {
el.style.outline = '2px solid #0ea5e9'
@ -219,7 +399,7 @@
markerObjects.push(
new maplibregl.Marker({ element: el })
.setLngLat([data.lng, data.lat])
.addTo(map),
.addTo(instance),
)
}
})
@ -234,20 +414,11 @@
highlightMarker = null
const coords = geojson?.coordinates
if (highlightDistance == null || !coords?.length || !coordDistances.length) return
if (highlightDistance == null || !coords?.length) return
// Nächstgelegene Koordinate zur gesuchten Distanz
let best = 0
let bestDiff = Infinity
for (let i = 0; i < coordDistances.length; i++) {
const diff = Math.abs(coordDistances[i] - highlightDistance)
if (diff < bestDiff) {
bestDiff = diff
best = i
}
}
const at = positionAtDistance(coords, coordDistances, highlightDistance)
if (!at) return
const idx = Math.min(coords.length - 1, best)
const el = document.createElement('div')
el.style.cssText = `
width: 12px; height: 12px; border-radius: 9999px;
@ -255,9 +426,7 @@
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
highlightMarker = new maplibregl.Marker({ element: el })
.setLngLat(coords[idx])
.addTo(map)
highlightMarker = new maplibregl.Marker({ element: el }).setLngLat(at).addTo(map)
})
</script>
@ -265,11 +434,52 @@
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}
<!--
Popups hängt maplibre selbst in den Karten-Container, deshalb greifen die
Regeln nur global. Bewusst helle Fläche wie das maplibre-Standardpopup —
im Dunkelmodus liegt es trotzdem lesbar über der hellen Karte.
-->
<style>
:global(.trail-popup-shell .maplibregl-popup-content) {
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
font-family: inherit;
max-width: 15rem;
}
:global(.trail-popup-head) {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.875rem;
color: #0f172a;
}
:global(.trail-popup-icon) {
display: inline-flex;
flex-shrink: 0;
}
:global(.trail-popup-badge) {
font-size: 0.6875rem;
padding: 0 0.35rem;
border-radius: 9999px;
background: #e2e8f0;
color: #475569;
}
:global(.trail-popup-note) {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: #334155;
}
:global(.trail-popup-meta) {
margin: 0.35rem 0 0;
font-size: 0.75rem;
color: #64748b;
}
</style>

View file

@ -13,7 +13,7 @@
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
class={cn("[&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
{...restProps}
>
{@render children?.()}

View file

@ -0,0 +1,49 @@
/**
* Icons für Flag-Typen.
*
* Erlaubt ist jeder Name aus lucide.dev die feste Liste unten sind nur
* Vorschläge fürs schnelle Klicken. Möglich macht das ein Glob über die
* Icon-Dateien des Pakets: Jedes Icon wird ein eigener Chunk und erst geladen,
* wenn es wirklich gebraucht wird. Ein statischer Import des ganzen Pakets
* hätte mehrere hundert Kilobyte für eine Handvoll Symbole gekostet, ein
* dynamischer Import mit Variable ließe sich vom Bundler nicht auflösen.
*/
type IconModule = { default: unknown }
const modules = import.meta.glob('/node_modules/lucide-svelte/dist/icons/*.svelte') as Record<
string,
() => Promise<IconModule>
>
/** `TreePine`, `tree-pine` und `treePine` führen zum selben Icon. */
export function toKebab(name: string): string {
return name
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase()
}
/** Lader für einen Icon-Namen; null, wenn es das Icon nicht gibt. */
export function iconLoader(name: string | undefined): (() => Promise<IconModule>) | null {
if (!name?.trim()) return null
return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null
}
/** Ob ein eingegebener Name existiert — für die Rückmeldung im Dialog. */
export function iconExists(name: string | undefined): boolean {
return iconLoader(name) !== null
}
/**
* Vorschläge für die Auswahl im Dialog. Bewusst nach Themen sortiert und
* nicht abschließend: Wer etwas anderes braucht, tippt den Namen ein.
*/
export const FLAG_ICON_SUGGESTIONS = [
'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info',
'TriangleAlert', 'Mountain', 'Droplets', 'CloudRain', 'Snowflake', 'Flame',
'Zap', 'Bike', 'Wrench', 'Hammer', 'Shovel', 'Route',
'Milestone', 'Signpost', 'Footprints', 'Fence', 'Lock', 'CircleParking',
'CameraOff', 'Skull', 'Bug', 'Leaf', 'Trash2', 'Flag',
]

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseGpx, haversine, simplify } from './gpx'
import { parseGpx, haversine, simplify, nearestOnLine, distanceAlong, positionAtDistance } from './gpx'
// Zwei Punkte in Hersbruck, rund 1,4 km auseinander.
const SAMPLE = `<?xml version="1.0"?>
@ -189,3 +189,103 @@ describe('coordDistances', () => {
}
})
})
describe('nearestOnLine', () => {
// Zwei Segmente entlang eines Breitengrads, dann nach Norden.
const LINE: [number, number][] = [
[11.43, 49.51],
[11.44, 49.51],
[11.44, 49.52],
]
it('projiziert einen Punkt neben der Linie auf das nächste Segment', () => {
const pos = nearestOnLine(LINE, 11.435, 49.5105)!
expect(pos.index).toBe(0)
expect(pos.lat).toBeCloseTo(49.51, 6)
expect(pos.lng).toBeCloseTo(11.435, 6)
expect(pos.t).toBeCloseTo(0.5, 3)
})
it('bleibt an den Enden auf der Linie', () => {
const before = nearestOnLine(LINE, 11.42, 49.51)!
expect(before.lng).toBeCloseTo(11.43, 6)
expect(before.t).toBe(0)
const after = nearestOnLine(LINE, 11.44, 49.53)!
expect(after.index).toBe(1)
expect(after.lat).toBeCloseTo(49.52, 6)
expect(after.t).toBe(1)
})
it('wählt das wirklich nächste Segment, nicht das erste passende', () => {
const pos = nearestOnLine(LINE, 11.4405, 49.5195)!
expect(pos.index).toBe(1)
})
it('liefert null ohne Koordinaten', () => {
expect(nearestOnLine([], 11.43, 49.51)).toBeNull()
})
})
describe('distanceAlong', () => {
const DISTANCES = [0, 1000, 1500]
it('interpoliert innerhalb eines Segments', () => {
expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.25 }, DISTANCES)).toBe(250)
expect(distanceAlong({ lng: 0, lat: 0, index: 1, t: 0.5 }, DISTANCES)).toBe(1250)
})
it('liefert den letzten Wert am Ende der Linie', () => {
expect(distanceAlong({ lng: 0, lat: 0, index: 2, t: 0 }, DISTANCES)).toBe(1500)
})
it('liefert null ohne Distanztabelle', () => {
expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.5 }, [])).toBeNull()
})
})
describe('positionAtDistance', () => {
const LINE: [number, number][] = [
[11.43, 49.51],
[11.44, 49.51],
[11.44, 49.52],
]
const DISTANCES = [0, 1000, 2000]
it('findet die Koordinate zu einem Streckenkilometer', () => {
const at = positionAtDistance(LINE, DISTANCES, 500)!
expect(at[0]).toBeCloseTo(11.435, 6)
expect(at[1]).toBeCloseTo(49.51, 6)
})
it('interpoliert auch im zweiten Segment', () => {
const at = positionAtDistance(LINE, DISTANCES, 1750)!
expect(at[0]).toBeCloseTo(11.44, 6)
expect(at[1]).toBeCloseTo(49.5175, 6)
})
it('klemmt Werte außerhalb der Strecke auf Anfang und Ende', () => {
expect(positionAtDistance(LINE, DISTANCES, -100)).toEqual(LINE[0])
expect(positionAtDistance(LINE, DISTANCES, 99999)).toEqual(LINE[2])
})
it('liefert null ohne Distanztabelle', () => {
expect(positionAtDistance(LINE, [], 500)).toBeNull()
})
})
describe('Zusammenspiel mit parseGpx', () => {
it('führt Projektion und Rückweg auf dieselbe Stelle', () => {
const parsed = parseGpx(SAMPLE)
const coords = parsed.geojson.coordinates
// Ein Punkt leicht neben der Mitte des zweiten Segments
const pos = nearestOnLine(coords, coords[1][0] + 0.0002, coords[1][1])!
const d = distanceAlong(pos, parsed.coordDistances)!
const back = positionAtDistance(coords, parsed.coordDistances, d)!
expect(back[0]).toBeCloseTo(pos.lng, 4)
expect(back[1]).toBeCloseTo(pos.lat, 4)
})
})

View file

@ -222,3 +222,112 @@ export function parseGpx(xml: string): ParsedGpx {
ascent_m: Math.round(ascent),
}
}
/** Ein auf die Trail-Linie projizierter Punkt. */
export type LinePosition = {
lng: number
lat: number
/** Index des Segmentanfangs in `coordinates` */
index: number
/** Lage im Segment zwischen 0 und 1 */
t: number
}
/**
* Projiziert einen beliebigen Punkt auf die nächstgelegene Stelle der Linie.
* Damit landet ein Marker exakt auf dem Track, egal wie genau geklickt wurde.
*
* Gerechnet wird in Grad, die Längengrade aber mit cos(lat) gestaucht sonst
* wäre ein Grad Ost-West bei uns fast doppelt so lang" wie einer Nord-Süd und
* die Projektion würde spürbar daneben liegen. Für die paar Meter Suchradius
* genügt das; eine echte Projektion wäre hier Aufwand ohne Gewinn.
*/
export function nearestOnLine(
coords: [number, number][],
lng: number,
lat: number,
): LinePosition | null {
if (!coords.length) return null
if (coords.length === 1) {
return { lng: coords[0][0], lat: coords[0][1], index: 0, t: 0 }
}
const kx = Math.cos((lat * Math.PI) / 180) || 1
let best: LinePosition | null = null
let bestDist = Infinity
for (let i = 0; i < coords.length - 1; i++) {
const [ax, ay] = coords[i]
const [bx, by] = coords[i + 1]
const dx = (bx - ax) * kx
const dy = by - ay
const px = (lng - ax) * kx
const py = lat - ay
const len = dx * dx + dy * dy
// Doppelter Punkt im Track: das Segment hat keine Richtung.
const t = len === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / len))
const ox = px - t * dx
const oy = py - t * dy
const dist = ox * ox + oy * oy
if (dist < bestDist) {
bestDist = dist
best = { lng: ax + (bx - ax) * t, lat: ay + (by - ay) * t, index: i, t }
}
}
return best
}
/**
* Streckenkilometer einer projizierten Stelle. `coordDistances` hat einen
* Eintrag je Koordinate; zwischen zwei Stützpunkten wird linear interpoliert.
* Ohne Distanztabelle (Versionen von vor dem Feld) gibt es kein Ergebnis.
*/
export function distanceAlong(pos: LinePosition, coordDistances: number[]): number | null {
if (coordDistances.length <= pos.index) return null
const a = coordDistances[pos.index]
const b = coordDistances[pos.index + 1]
if (b == null) return a
return a + (b - a) * pos.t
}
/**
* Gegenrichtung zu `distanceAlong`: die Koordinate zu einem Streckenkilometer.
* Ebenfalls interpoliert, damit ein Punkt beim Überfahren gleichmäßig wandert
* statt von Stützpunkt zu Stützpunkt zu springen.
*/
export function positionAtDistance(
coords: [number, number][],
coordDistances: number[],
distance: number,
): [number, number] | null {
if (!coords.length || coordDistances.length < 2) return null
const last = Math.min(coords.length, coordDistances.length) - 1
if (distance <= coordDistances[0]) return coords[0]
if (distance >= coordDistances[last]) return coords[last]
// Binäre Suche nach dem Segment, in dem die Distanz liegt.
let lo = 0
let hi = last
while (hi - lo > 1) {
const mid = (lo + hi) >> 1
if (coordDistances[mid] <= distance) lo = mid
else hi = mid
}
const span = coordDistances[hi] - coordDistances[lo]
const t = span === 0 ? 0 : (distance - coordDistances[lo]) / span
return [
coords[lo][0] + (coords[hi][0] - coords[lo][0]) * t,
coords[lo][1] + (coords[hi][1] - coords[lo][1]) * t,
]
}

View file

@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import { dayOf, todayISO } from '$lib/time'
import type { EventsResponse } from '$lib/types'
const KEY = Symbol('event')
@ -87,14 +88,56 @@ export class EventStore {
return this.records.find((r) => r.id === id)
}
get active() {
return this.scoped.filter((r) => r.status === 'active')
/**
* Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen
* hinten dran sie sind noch nicht terminiert.
*/
get byDate(): EventsResponse[] {
return [...this.scoped].sort((a, b) => {
const da = dayOf(a.starts)
const db = dayOf(b.starts)
if (!da && !db) return 0
if (!da) return 1
if (!db) return -1
return da < db ? -1 : da > db ? 1 : 0
})
}
get drafts() {
return this.scoped.filter((r) => r.status === 'draft')
/**
* Läuft heute. Ohne Enddatum zählt der Starttag allein genau dann ist
* ein eintägiges Event heute".
*/
get active(): EventsResponse[] {
const today = todayISO()
return this.byDate.filter((r) => {
const from = dayOf(r.starts)
if (!from) return false
const to = dayOf(r.ends) || from
return from <= today && today <= to
})
}
get finished() {
return this.scoped.filter((r) => r.status === 'finished')
/** Steht noch an. */
get upcoming(): EventsResponse[] {
const today = todayISO()
return this.byDate.filter((r) => dayOf(r.starts) > today)
}
/** Vorbei — der letzte Tag liegt hinter uns. */
get past(): EventsResponse[] {
const today = todayISO()
return this.byDate
.filter((r) => {
const from = dayOf(r.starts)
if (!from) return false
return (dayOf(r.ends) || from) < today
})
.reverse()
}
/** Noch ohne Termin. */
get undated(): EventsResponse[] {
return this.byDate.filter((r) => !dayOf(r.starts))
}
destroy() {

View file

@ -2,7 +2,7 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import type { RidersResponse } from '$lib/types'
import type { RidersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('rider')
@ -34,6 +34,15 @@ export class RiderStore {
return this.records.filter((r) => r.team === teamId)
}
/**
* Anlegen, ändern und löschen darf nur die Teamleitung dieselbe Regel
* wie in createRule/updateRule/deleteRule der Collection. Die Prüfung
* hier blendet nur UI aus; durchgesetzt wird sie serverseitig.
*/
get canManage(): boolean {
return this.teams.isAdmin()
}
async load() {
this.loading = true
this.error = null
@ -102,6 +111,34 @@ export class RiderStore {
return this.records.find((r) => r.id === id)
}
/** Fahrer, der zu einem Login gehört — für „das bin ich"-Ansichten. */
getByUser(userId: string) {
return this.scoped.filter((r) => r.user === userId)
}
/** Bestehendes Konto zuordnen oder die Zuordnung lösen. */
async linkUser(riderId: string, userId: string | null) {
return await this.edit(riderId, { user: userId ?? '' } as Partial<RidersResponse>)
}
/**
* Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im
* Team-Store es ist zuerst ein Teammitglied und erst dann dieser Fahrer.
*/
async createLogin(
rider: RidersResponse,
data: { email: string; password: string; name?: string },
): Promise<UsersResponse> {
const user = await this.teams.createMember({
...data,
name: data.name?.trim() || fullName(rider),
})
await this.linkUser(rider.id, user.id)
return user
}
getByEvent(eventId: string) {
return this.scoped.filter((r) => r.event === eventId)
}

View file

@ -12,6 +12,13 @@ export class TeamStore {
loading = $state(false)
error = $state<string | null>(null)
/**
* Aufgelöste User-Datensätze, nach ID. Ein Team kennt nur die IDs seiner
* Mitglieder; die Namen holt sich die Oberfläche einzeln nach. Cache,
* damit dieselbe ID nicht auf jeder Seite erneut geladen wird.
*/
users = $state<Record<string, UsersResponse>>({})
private unsubscribe: (() => void) | null = null
get active(): TeamsResponse | null {
@ -168,6 +175,63 @@ export class TeamStore {
})
}
/**
* Einen User nachladen. Sichtbar ist ein fremder Datensatz nur über seine
* ID (users.viewRule); durchblättern lässt sich die Collection nicht.
*/
async loadUser(id: string): Promise<UsersResponse | null> {
if (this.users[id]) return this.users[id]
try {
const u = await api.collection('users').getOne(id, { requestKey: null })
this.users = { ...this.users, [id]: u }
return u
} catch {
return null
}
}
/** Alle Mitglieder eines Teams in den Cache holen. */
async loadMembers(team: TeamsResponse | null = this.active) {
if (!team) return
const ids = [...(team.users ?? []), team.owner].filter(Boolean) as string[]
await Promise.all(ids.filter((id) => !this.users[id]).map((id) => this.loadUser(id)))
}
/** Anzeigename zu einer User-ID; fällt auf eine gekürzte ID zurück. */
userLabel(id: string): string {
const u = this.users[id]
if (!u) return id.slice(0, 6) + '…'
return u.name || u.email || id.slice(0, 6) + '…'
}
/**
* Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die
* REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das
* Konto bereits der Aufrufer meldet den Fehler, statt still
* weiterzumachen.
*
* Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem
* eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung
* gibt es weiterhin nicht.
*/
async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) {
if (!teamId) throw new Error('Kein aktives Team')
const user = (await api.collection('users').create({
email: data.email.trim(),
emailVisibility: false,
password: data.password,
passwordConfirm: data.password,
name: data.name?.trim() || data.email.trim(),
})) as UsersResponse
this.users = { ...this.users, [user.id]: user }
await this.addMember(teamId, user.id)
return user
}
/** User per E-Mail suchen (für Einladungs-UI) */
async findUserByEmail(email: string): Promise<UsersResponse | null> {
try {

View file

@ -0,0 +1,22 @@
/**
* Laufende Uhr für Live-Anzeigen. Wird im Komponenten-Setup aufgerufen; das
* Intervall hängt am Effekt-Kontext der Komponente und endet mit ihr.
*
* Der Wert wird bewusst als Zahl herausgereicht statt als fertiger Text:
* Ableitungen, die `now` lesen, rechnen dadurch bei jedem Tick neu ohne
* `{#key}`-Blöcke, die sonst ganze Listen neu aufbauen würden.
*/
export function ticker(intervalMs = 100) {
let now = $state(Date.now())
$effect(() => {
const id = setInterval(() => (now = Date.now()), intervalMs)
return () => clearInterval(id)
})
return {
get now() {
return now
},
}
}

View file

@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import { formatDuration } from '$lib/time'
import type { TimesResponse, TimesStatusOptions } from '$lib/types'
const KEY = Symbol('time')
@ -13,18 +14,22 @@ export interface TimeWithDuration extends TimesResponse {
formattedTime?: string
}
function enrich(t: TimesResponse): TimeWithDuration {
/**
* `now` steckt als Parameter drin, damit laufende Zeiten live weiterzählen:
* Eine Ableitung, die einen tickenden Zeitwert hereinreicht, rechnet bei
* jedem Tick neu. Ohne den Parameter wäre `Date.now()` für Svelte keine
* Abhängigkeit und die Anzeige stünde still.
*/
function enrich(t: TimesResponse, now: number): TimeWithDuration {
if (!t.start) return t
const start = new Date(t.start).getTime()
const end = t.stop ? new Date(t.stop).getTime() : Date.now()
const end = t.stop ? new Date(t.stop).getTime() : now
let duration = (end - start) / 1000
if (t.correction) duration += t.correction
const minutes = Math.floor(duration / 60)
const seconds = (duration % 60).toFixed(1)
return {
...t,
duration,
formattedTime: `${minutes}:${seconds.padStart(4, '0')}`,
formattedTime: formatDuration(duration),
}
}
@ -123,12 +128,29 @@ export class TimeStore {
})
}
getByRun(runId: string): TimeWithDuration[] {
return this.scoped.filter((r) => r.run === runId).map(enrich)
getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
}
getByRider(riderId: string): TimeWithDuration[] {
return this.scoped.filter((r) => r.rider === riderId).map(enrich)
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
}
/** Laufende Zeiten eines Runs — der am längsten laufende zuerst. */
runningByRun(runId: string, now = Date.now()): TimeWithDuration[] {
return this.getByRun(runId, now)
.filter((r) => r.status === 'active')
.sort((a, b) => (a.start < b.start ? -1 : 1))
}
/**
* Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf,
* dürfen aber auch nicht unsichtbar werden.
*/
unrankedByRun(runId: string): TimeWithDuration[] {
return this.getByRun(runId).filter(
(r) => r.status !== 'active' && r.status !== 'finished',
)
}
isRiderActive(runId: string, riderId: string) {
@ -144,11 +166,23 @@ export class TimeStore {
}
get running(): TimeWithDuration[] {
return this.scoped.filter((r) => r.status === 'active').map(enrich)
return this.allRunning()
}
/**
* Alle laufenden Zeiten des Teams, run-übergreifend der am längsten
* laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
*/
allRunning(now = Date.now()): TimeWithDuration[] {
return this.scoped
.filter((r) => r.status === 'active')
.map((r) => enrich(r, now))
.sort((a, b) => (a.start < b.start ? -1 : 1))
}
get finished(): TimeWithDuration[] {
return this.scoped.filter((r) => r.status === 'finished').map(enrich)
const now = Date.now()
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
}
getLeaderboard(runId: string): TimeWithDuration[] {
@ -157,6 +191,12 @@ export class TimeStore {
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
}
/** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */
placement(runId: string, riderId: string): number | null {
const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
return idx === -1 ? null : idx + 1
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()

View file

@ -105,14 +105,19 @@ export class TrailFlagStore {
* 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
async seedDefaults(teamId = this.teams.activeId): Promise<number> {
if (!teamId) throw new Error('Kein aktives Team')
if (!this.canManage) {
// Beim Anlegen eines Teams ist man dessen Owner, aber der Datensatz
// steckt womöglich noch nicht im Store — dann greift die Prüfung ins
// Leere. Für das eigene, gerade erzeugte Team wird sie übersprungen;
// die createRule der Collection setzt sie ohnehin serverseitig durch.
if (teamId === this.teams.activeId && !this.canManage) {
throw new Error('Dafür brauchst du Administratorrechte im Team.')
}
const existing = new Set(this.scoped.map((f) => f.label))
const existing = new Set(
this.records.filter((f) => f.team === teamId).map((f) => f.label),
)
let created = 0
for (const flag of DEFAULT_FLAGS) {

View file

@ -1,12 +1,20 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import type { TrailMarkersResponse } from '$lib/types'
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('trailMarker')
/**
* Marker samt aufgelöstem Melder. Ob `expand.created_by` gefüllt ist, hängt
* an den Sichtbarkeitsregeln der users-Collection deshalb optional.
*/
export type TrailMarker = Omit<TrailMarkersResponse, 'expand'> & {
expand?: { created_by?: UsersResponse }
}
export class TrailMarkerStore {
records = $state<TrailMarkersResponse[]>([])
records = $state<TrailMarker[]>([])
loading = $state(false)
error = $state<string | null>(null)
@ -14,14 +22,14 @@ export class TrailMarkerStore {
private teams = getTeamContext()
/** Alle Marker eines Trails, jüngste zuerst */
byTrail(trailId: string): TrailMarkersResponse[] {
byTrail(trailId: string): TrailMarker[] {
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[] {
openByTrail(trailId: string): TrailMarker[] {
return this.byTrail(trailId).filter((r) => !r.resolved)
}
@ -30,7 +38,7 @@ export class TrailMarkerStore {
* Team-Admins dieselbe Regel wie in der updateRule der Collection.
* Der Trail wird übergeben, weil der Store ihn nicht kennt.
*/
canEdit(marker: TrailMarkersResponse, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean {
canEdit(marker: TrailMarker, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean {
const uid = auth.user?.id
if (!uid) return false
@ -42,12 +50,25 @@ export class TrailMarkerStore {
)
}
/**
* Wer den Marker gemeldet hat. Fremde User-Datensätze sind derzeit nicht
* sichtbar (users.listRule: `id = @request.auth.id`), deshalb bleibt der
* Name bei Meldungen anderer offen.
*/
authorName(marker: TrailMarker): string {
const name = marker.expand?.created_by?.name
if (name) return name
if (marker.created_by && marker.created_by === auth.user?.id) return 'Du'
return 'Unbekannt'
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('trail_markers').getFullList({
sort: '-created',
expand: 'created_by',
requestKey: null,
})
} catch (e: any) {
@ -61,16 +82,31 @@ export class TrailMarkerStore {
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
// Realtime liefert den Datensatz ungetypt; angefordert wird unten
// dasselbe expand wie beim Laden.
const record = e.record as TrailMarker
const idx = this.records.findIndex((r) => r.id === record.id)
if (e.action === 'create' && idx === -1) this.records = [record, ...this.records]
else if (e.action === 'update' && idx !== -1) this.records[idx] = record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => {
}, { expand: 'created_by' }).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 }) {
/**
* `flag` ist optional: Ein Marker darf mit eigenem `label` und `icon`
* auskommen, wenn kein vorgefertigter Typ passt.
*/
async create(data: {
trail: string
flag?: string
label?: string
icon?: string
lat: number
lng: number
note?: string
}) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
@ -82,7 +118,7 @@ export class TrailMarkerStore {
})
}
async edit(id: string, data: Partial<TrailMarkersResponse>) {
async edit(id: string, data: Partial<TrailMarker>) {
return await api.collection('trail_markers').update(id, data)
}

View file

@ -92,6 +92,18 @@ export class TrailStore {
return await api.collection('trails').update(id, data)
}
/**
* Status melden. Wann und von wem kommt gleich mit ohne das ließe sich
* einer Sperrung nicht ansehen, ob sie von heute oder vom letzten Jahr ist.
*/
async setStatus(id: string, status: string) {
return await api.collection('trails').update(id, {
status,
status_changed: new Date().toISOString(),
status_by: auth.user?.id ?? '',
})
}
async setStewards(id: string, userIds: string[]) {
return await api.collection('trails').update(id, { stewards: userIds })
}

72
frontend/src/lib/time.ts Normal file
View file

@ -0,0 +1,72 @@
/**
* Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch
* Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll
* überall dieselbe sein.
*/
/** Dauer in Sekunden als `m:ss.s`. */
export function formatDuration(seconds: number | undefined): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '0:00.0'
const m = Math.floor(seconds / 60)
const s = (seconds % 60).toFixed(1)
return `${m}:${s.padStart(4, '0')}`
}
/** Beschriftungen der Zeit-Status aus der Collection. */
export const TIME_STATUS_LABEL: Record<string, string> = {
active: 'Läuft',
finished: 'Beendet',
dnf: 'DNF',
dns: 'DNS',
dsq: 'DSQ',
}
/** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */
export function formatDateTime(iso: string | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
}
/** Heutiges Datum als `JJJJ-MM-TT` in lokaler Zeit (nicht UTC). */
export function todayISO(): string {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
/**
* Datumsanteil eines PocketBase-Werts. Die kommen als
* `2026-09-12 00:00:00.000Z` zurück; für Vergleiche und `<input type="date">`
* zählt nur der Tag.
*/
export function dayOf(value: string | undefined): string {
if (!value) return ''
return value.slice(0, 10)
}
/** Ein Tag als `TT.MM.JJJJ`. */
export function formatDay(value: string | undefined): string {
const day = dayOf(value)
if (!day) return ''
const [y, m, d] = day.split('-')
return `${d}.${m}.${y}`
}
/**
* Zeitraum eines Events. Ohne Enddatum oder bei gleichem Tag steht nur ein
* Datum da; sonst 12.09.2026 14.09.2026".
*/
export function formatDateRange(starts: string | undefined, ends: string | undefined): string {
const from = formatDay(starts)
const to = formatDay(ends)
if (!from && !to) return ''
if (!from) return to
if (!to || to === from) return from
return `${from} ${to}`
}

View file

@ -0,0 +1,38 @@
/**
* Zustände eines Trails samt Farbe und Beschriftung.
*
* An einer Stelle, weil dieselben Farben in der Kartenlinie, im Abzeichen und
* im Auswahlmenü auftauchen liefen sie auseinander, hieße Grün auf der Karte
* etwas anderes als Grün daneben.
*/
export type TrailStatusValue = 'offen' | 'eingeschraenkt' | 'gesperrt'
export type TrailStatusInfo = {
value: TrailStatusValue
label: string
/** Als CSS-Farbe, auch für MapLibre-Paint-Eigenschaften verwendbar. */
color: string
/** Kurzer Hinweis, was der Zustand bedeutet. */
hint: string
}
export const TRAIL_STATUS: TrailStatusInfo[] = [
{ value: 'offen', label: 'Offen', color: '#16a34a', hint: 'befahrbar' },
{
value: 'eingeschraenkt',
label: 'Eingeschränkt',
color: '#ca8a04',
hint: 'befahrbar, aber mit Hindernissen',
},
{ value: 'gesperrt', label: 'Gesperrt', color: '#dc2626', hint: 'nicht befahren' },
]
const BY_VALUE = new Map(TRAIL_STATUS.map((s) => [s.value, s]))
export function trailStatus(value: string | undefined): TrailStatusInfo {
return (value && BY_VALUE.get(value as TrailStatusValue)) || TRAIL_STATUS[0]
}
export function trailStatusColor(value: string | undefined): string {
return trailStatus(value).color
}

View file

@ -46,19 +46,15 @@ export type AuthSystemFields<T = unknown> = {
// Record types for each collection
export enum EventsStatusOptions {
"draft" = "draft",
"active" = "active",
"finished" = "finished",
}
export type EventsRecord = {
created?: IsoDateString
description?: string
ends?: IsoDateString
gps?: string
id: string
location?: string
name?: string
status?: EventsStatusOptions
starts?: IsoDateString
team?: RecordIdString
updated?: IsoDateString
}
@ -74,6 +70,7 @@ export type RidersRecord = {
number?: string
team?: RecordIdString
updated?: IsoDateString
user?: RecordIdString
}
export type RunsRecord = {
@ -147,8 +144,10 @@ export type TrailFlagsRecord = {
export type TrailMarkersRecord = {
created?: IsoDateString
created_by?: RecordIdString
flag: RecordIdString
flag?: RecordIdString
icon?: string
id: string
label?: string
lat: number
lng: number
note?: string
@ -188,6 +187,8 @@ export type TrailsRecord = {
id: string
name: string
status?: TrailsStatusOptions
status_by?: RecordIdString
status_changed?: IsoDateString
stewards?: RecordIdString[]
team: RecordIdString
updated?: IsoDateString
@ -201,6 +202,7 @@ export type UsersRecord = {
id: string
name?: string
password: string
superadmin?: boolean
tokenKey: string
updated?: IsoDateString
username: string

View file

@ -15,7 +15,9 @@
import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte'
import TeamSwitcher from '$lib/components/TeamSwitcher.svelte'
import { Home, Calendar, Flag, Timer, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte'
import Avatar from '$lib/components/Avatar.svelte'
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Bike } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte'
@ -82,13 +84,14 @@
})
}
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
// passiert dort, wo es hingehört — am Run innerhalb seines Events.
const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
{ href: '/dashboard/riders', icon: Bike, label: 'Fahrer' },
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
{ href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' },
{ href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' },
{ href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
]
</script>
@ -96,43 +99,73 @@
<div class="min-h-screen bg-background">
<header class="border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-2">
<Logo />
<div class="hidden md:block">
<TeamSwitcher />
</div>
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<div class="flex min-h-16 items-center justify-between gap-4 py-2">
<!-- Links: Logo und Menü. Das Logo fuellt seine Box, in
voller Groesse wuerde es die 64 px hohe Leiste
ueberragen und beschnitten wirken. -->
<div class="flex items-center gap-3 min-w-0">
<Logo class="h-11 w-11 shrink-0" />
<nav class="hidden md:flex items-center gap-1">
{#each nav as item}
{@const Icon = item.icon}
<a
href={item.href}
class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</a>
{/each}
</nav>
</div>
<nav class="hidden md:flex items-center gap-1">
{#each nav as item}
{@const Icon = item.icon}
<a
href={item.href}
class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</a>
{/each}
</nav>
<!-- Rechts eine Spalte: Name samt Design und Logout in einer
Zeile, das Team-Dropdown darunter. -->
<div class="flex items-center gap-2">
<div class="hidden md:flex items-center gap-2">
<div class="text-sm text-right">
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
<!-- Wer man ist, steht links; was man tun kann, rechts. -->
<div class="hidden md:flex min-w-56 flex-col gap-1">
<div class="flex items-center justify-between gap-3">
<a
href="/dashboard/settings"
title="Profil"
class="flex min-w-0 items-center gap-2"
>
<Avatar user={auth.user} size={24} />
<span class="truncate text-sm font-medium leading-none">
{auth.user.name || auth.user.email}
</span>
</a>
<span class="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={toggleMode}
title="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={handleLogout}
title="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
</span>
</div>
<div class="flex">
<TeamSwitcher />
</div>
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
<LogOut class="h-4 w-4" />
</Button>
</div>
<Button
@ -167,14 +200,28 @@
</a>
{/each}
<Separator class="my-2" />
<div class="flex items-center justify-between px-3 py-2">
<div class="text-sm">
<p class="font-medium leading-tight">{auth.user.name || auth.user.email}</p>
<p class="text-xs text-muted-foreground leading-tight">{auth.user.email}</p>
<div class="flex items-center justify-between gap-2 px-3 py-2">
<div class="flex flex-col items-start gap-1 min-w-0">
<span class="flex items-center gap-2 min-w-0">
<Avatar user={auth.user} size={24} />
<span class="text-sm font-medium leading-none truncate">
{auth.user.name || auth.user.email}
</span>
</span>
<TeamSwitcher />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
<LogOut class="h-4 w-4" />
</Button>
</div>
<Button variant="ghost" size="icon" onclick={handleLogout}>
<LogOut class="h-4 w-4" />
</Button>
</div>
</nav>
</div>
@ -184,5 +231,8 @@
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{@render children?.()}
</main>
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
<RunningTimesToast />
</div>
{/if}

View file

@ -8,7 +8,8 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Calendar, Users, Flag, Timer, Clock, MapPin } from 'lucide-svelte'
import { formatDateRange } from '$lib/time'
import { Calendar, Users, Clock, MapPin, Route, UsersRound } from 'lucide-svelte'
const teams = getTeamContext()
const events = getEventContext()
@ -16,6 +17,10 @@
const riders = getRiderContext()
const times = getTimeContext()
// „Aktiv" heißt jetzt: findet heute statt. Für die Startseite ist das
// zusammen mit dem Anstehenden interessant.
const upNext = $derived([...events.active, ...events.upcoming].slice(0, 4))
const stats = $derived({
totalEvents: events.scoped.length,
activeEvents: events.active.length,
@ -37,7 +42,7 @@
Team <span class="font-medium text-foreground">{teams.active.name}</span> · {stats.totalEvents} Events, {stats.totalRiders} Fahrer
</p>
{:else}
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/teams" class="underline">Team verwalten</a>.</p>
<p class="mt-2 text-muted-foreground">Kein Team aktiv. <a href="/dashboard/settings/team" class="underline">Team verwalten</a>.</p>
{/if}
</div>
@ -49,7 +54,7 @@
</CardHeader>
<CardContent>
<div class="text-3xl font-bold">{stats.totalEvents}</div>
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} aktiv</p>
<p class="text-xs text-muted-foreground mt-1">{stats.activeEvents} heute</p>
</CardContent>
</Card>
@ -87,44 +92,48 @@
</a>
</Card>
<Card class="hover:border-primary transition-colors">
<a href="/dashboard/timing" class="block p-6">
<Timer class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Zeitnahme</h3>
<p class="text-sm text-muted-foreground mt-1">Zeiten starten und stoppen</p>
<a href="/dashboard/trails" class="block p-6">
<Route class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Trails</h3>
<p class="text-sm text-muted-foreground mt-1">Strecken, Zustand und Meldungen</p>
</a>
</Card>
<Card class="hover:border-primary transition-colors">
<a href="/dashboard/results" class="block p-6">
<Flag class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Ergebnisse</h3>
<p class="text-sm text-muted-foreground mt-1">Zeiten und Rankings</p>
<a href="/dashboard/settings/team" class="block p-6">
<UsersRound class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Teams</h3>
<p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p>
</a>
</Card>
</div>
</div>
<div>
<h2 class="text-xl font-semibold mb-4">Aktive Events</h2>
{#if events.active.length === 0}
<h2 class="text-xl font-semibold mb-4">Heute und demnächst</h2>
{#if upNext.length === 0}
<Card>
<CardContent class="py-12 text-center">
<Calendar class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Keine aktiven Events</p>
<p class="font-medium">Nichts geplant</p>
<p class="text-sm text-muted-foreground mt-1">
Aktiviere ein Event über die Eventliste, um mit der Zeitnahme zu beginnen.
Trag einem Event ein Datum ein, dann steht es hier.
</p>
<Button href="/dashboard/events" variant="outline" class="mt-4">Events ansehen</Button>
</CardContent>
</Card>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each events.active as event}
{#each upNext as event}
{@const eventRuns = runs.getByEvent(event.id).length}
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<CardTitle>{event.name || event.description || 'Unbenannt'}</CardTitle>
<Badge>Aktiv</Badge>
{#if event.starts}
<Badge variant="secondary" class="shrink-0">
{formatDateRange(event.starts, event.ends)}
</Badge>
{/if}
</div>
{#if event.location}
<CardDescription class="flex items-center gap-1">
@ -135,7 +144,7 @@
</CardHeader>
<CardContent class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
<Button href="/dashboard/timing?event={event.id}" size="sm">Zeitnahme</Button>
<Button href="/dashboard/events/{event.id}" size="sm">Zum Event</Button>
</CardContent>
</Card>
{/each}

View file

@ -7,25 +7,24 @@
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 { dayOf, formatDateRange } from '$lib/time'
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
import type { EventsResponse, EventsStatusOptions } from '$lib/types'
import type { EventsResponse } from '$lib/types'
const events = getEventContext()
const runs = getRunContext()
let showDialog = $state(false)
let editing = $state<EventsResponse | null>(null)
let form = $state<{
name: string
description: string
location: string
status: EventsStatusOptions
}>({ name: '', description: '', location: '', status: 'draft' as EventsStatusOptions })
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
let multiDay = $state(false)
let formError = $state<string | null>(null)
function openCreate() {
editing = null
form = { name: '', description: '', location: '', status: 'draft' as EventsStatusOptions }
form = { name: '', description: '', location: '', starts: '', ends: '' }
multiDay = false
formError = null
showDialog = true
}
@ -35,29 +34,47 @@
name: e.name ?? '',
description: e.description ?? '',
location: e.location ?? '',
status: e.status ?? ('draft' as EventsStatusOptions),
starts: dayOf(e.starts),
ends: dayOf(e.ends),
}
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
multiDay = !!dayOf(e.ends)
formError = null
showDialog = true
}
async function save() {
if (!form.name.trim()) return
if (multiDay && !form.starts) {
formError = 'Ohne Beginn ergibt ein Ende keinen Sinn.'
return
}
if (multiDay && form.ends && form.ends < form.starts) {
formError = 'Das Ende liegt vor dem Beginn.'
return
}
formError = null
try {
if (editing) await events.edit(editing.id, form)
else await events.create(form)
// Ein eintägiges Event hat kein Ende — sonst stünde überall
// dasselbe Datum zweimal.
const ends = multiDay && form.ends !== form.starts ? form.ends : ''
const data = { ...form, ends }
if (editing) await events.edit(editing.id, data)
else await events.create(data)
showDialog = false
} catch (e: any) {
alert(`Fehler: ${e.message}`)
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
}
}
const statusLabels = { draft: 'Entwurf', active: 'Aktiv', finished: 'Abgeschlossen' }
const statusVariants = { draft: 'secondary', active: 'default', finished: 'outline' } as const
const groups = $derived([
{ title: 'Aktive Events', items: events.active, status: 'active' as const, showZero: false },
{ title: 'Entwürfe', items: events.drafts, status: 'draft' as const, showZero: false },
{ title: 'Abgeschlossen', items: events.finished, status: 'finished' as const, showZero: false },
{ title: 'Läuft gerade', items: events.active },
{ title: 'Anstehend', items: events.upcoming },
{ title: 'Ohne Termin', items: events.undated },
{ title: 'Vorbei', items: events.past },
])
</script>
@ -102,9 +119,11 @@
<CardHeader>
<div class="flex items-start justify-between gap-2">
<CardTitle class="leading-tight">{event.name || 'Unbenannt'}</CardTitle>
<Badge variant={statusVariants[event.status as keyof typeof statusVariants] ?? 'secondary'}>
{statusLabels[event.status as keyof typeof statusLabels] ?? 'Entwurf'}
</Badge>
{#if event.starts}
<Badge variant="secondary" class="shrink-0">
{formatDateRange(event.starts, event.ends)}
</Badge>
{/if}
</div>
{#if event.location}
<CardDescription class="flex items-center gap-1">
@ -168,19 +187,30 @@
<Input id="event-desc" bind:value={form.description} placeholder="Optionale Beschreibung" />
</div>
<div class="space-y-2">
<Label>Status</Label>
<Select.Root type="single" bind:value={form.status}>
<Select.Trigger>
{statusLabels[form.status]}
</Select.Trigger>
<Select.Content>
<Select.Item value="draft">Entwurf</Select.Item>
<Select.Item value="active">Aktiv</Select.Item>
<Select.Item value="finished">Abgeschlossen</Select.Item>
</Select.Content>
</Select.Root>
<Label for="event-starts">Datum</Label>
<Input id="event-starts" type="date" bind:value={form.starts} />
</div>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={multiDay}
class="size-4 rounded border-input accent-primary"
/>
mehrtägig
</label>
{#if multiDay}
<div class="space-y-2">
<Label for="event-ends">Bis</Label>
<Input id="event-ends" type="date" bind:value={form.ends} min={form.starts} />
</div>
{/if}
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (showDialog = false)}>Abbrechen</Button>
<Button type="submit">{editing ? 'Speichern' : 'Erstellen'}</Button>

View file

@ -1,8 +1,12 @@
<script lang="ts">
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import TimeDialog from '$lib/components/TimeDialog.svelte'
import { formatDateRange } from '$lib/time'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -20,18 +24,31 @@
Timer,
Trash2,
Users,
Flag,
} from 'lucide-svelte'
import type { RidersResponse, RunsResponse } from '$lib/types'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
function riderPath(riderId: string) {
return `/dashboard/events/${event?.id}/riders/${riderId}`
}
function runPath(runId: string) {
return `/dashboard/events/${event?.id}/runs/${runId}`
}
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
let timeDialog = $state(false)
// Fahrer-Dialog
let riderDialog = $state(false)
let ridingEdit = $state<RidersResponse | null>(null)
@ -125,9 +142,12 @@
</a>
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold tracking-tight">{event.name || 'Unbenannt'}</h1>
<Badge variant={event.status === 'active' ? 'default' : event.status === 'finished' ? 'outline' : 'secondary'}>
{event.status === 'active' ? 'Aktiv' : event.status === 'finished' ? 'Abgeschlossen' : 'Entwurf'}
</Badge>
{#if event.starts}
<Badge variant="secondary" class="gap-1">
<Calendar class="h-3 w-3" />
{formatDateRange(event.starts, event.ends)}
</Badge>
{/if}
</div>
{#if event.location}
<p class="mt-1 text-muted-foreground flex items-center gap-1">
@ -139,18 +159,14 @@
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
{/if}
</div>
<div class="flex items-center gap-2">
{#if event.status === 'active'}
<Button href="/dashboard/timing?event={event.id}">
<Timer class="h-4 w-4 mr-2" />
Zeitnahme
</Button>
{/if}
<Button href="/dashboard/results?event={event.id}" variant="outline">
<Flag class="h-4 w-4 mr-2" />
Ergebnisse
</Button>
</div>
<Button
onclick={() => (timeDialog = true)}
disabled={eventRiders.length === 0 || eventRuns.length === 0}
>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
<Separator />
@ -164,12 +180,14 @@
Fahrer
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
</h2>
<p class="text-sm text-muted-foreground">Teilnehmer dieses Events</p>
<p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
</div>
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Fahrer hinzufügen
</Button>
{#if riders.canManage}
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Fahrer hinzufügen
</Button>
{/if}
</div>
<Card>
@ -178,11 +196,17 @@
<div class="py-12 text-center">
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Fahrer</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Ersten Fahrer hinzufügen
</Button>
{#if riders.canManage}
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Ersten Fahrer hinzufügen
</Button>
{:else}
<p class="text-sm text-muted-foreground mt-1">
Fahrer legt die Teamleitung an.
</p>
{/if}
</div>
{:else}
<Table.Root>
@ -190,29 +214,39 @@
<Table.Row>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head class="w-40">Zeiten</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each eventRiders as rider (rider.id)}
<Table.Row>
{@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
<Table.Cell>
<Badge variant="outline" class="font-mono">{rider.number || '—'}</Badge>
<Badge variant="outline">{rider.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{#if fullName(rider)}
{fullName(rider)}
{:else}
<span class="text-muted-foreground italic">— ohne Name —</span>
{/if}
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
<a href={riderPath(rider.id)} class="hover:underline">
{#if fullName(rider)}
{fullName(rider)}
{:else}
<span class="text-muted-foreground italic">— ohne Name —</span>
{/if}
</a>
</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
<Table.Cell class="text-muted-foreground text-sm">
{done} von {eventRuns.length} Runs
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
{#if riders.canManage}
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
{/if}
</Table.Cell>
</Table.Row>
{/each}
@ -234,7 +268,7 @@
Runs
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
</h2>
<p class="text-sm text-muted-foreground">Einzelne Läufe/Stages des Events</p>
<p class="text-sm text-muted-foreground">Klick auf einen Run öffnet die Zeitnahme</p>
</div>
<Button onclick={openRunCreate}>
<Plus class="h-4 w-4 mr-2" />
@ -259,14 +293,26 @@
<Table.Header>
<Table.Row>
<Table.Head>Bezeichnung</Table.Head>
<Table.Head class="w-48">Zeiten</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each eventRuns as run (run.id)}
<Table.Row>
<Table.Cell class="font-medium">{run.description}</Table.Cell>
<Table.Cell class="text-right">
{@const runTimes = times.getByRun(run.id)}
{@const active = runTimes.filter((t) => t.status === 'active').length}
{@const ranked = runTimes.filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
<Table.Cell class="font-medium">
<a href={runPath(run.id)} class="hover:underline">{run.description}</a>
</Table.Cell>
<Table.Cell>
{#if active > 0}
<Badge class="mr-2">{active} laufend</Badge>
{/if}
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
<Pencil class="h-4 w-4" />
</Button>
@ -350,3 +396,5 @@
</form>
</Dialog.Content>
</Dialog.Root>
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} />

View file

@ -0,0 +1,242 @@
<script lang="ts">
/**
* Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
* wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
* Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
*/
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
import TimeDialog from '$lib/components/TimeDialog.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as Table from '@/components/ui/table'
import { ArrowLeft, ChevronRight, Timer, Users } from 'lucide-svelte'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const clock = ticker()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const rider = $derived(page.params.riderId ? riders.getById(page.params.riderId) : undefined)
const valid = $derived(!!event && !!rider && rider.event === event.id)
/**
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
* bleibt, was dem Fahrer noch fehlt.
*/
const rows = $derived.by(() => {
if (!valid || !event || !rider) return []
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
// aktuell; bei einer Handvoll Runs ist das billig.
const own = times.getByRider(rider.id, clock.now)
return runs.getByEvent(event.id).map((run) => {
const forRun = own.filter((t) => t.run === run.id)
const active = forRun.find((t) => t.status === 'active')
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
const best = forRun
.filter((t) => t.status === 'finished')
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
return {
run,
active,
best,
others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
placement: best ? times.placement(run.id, rider.id) : null,
}
})
})
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
let timeDialog = $state(false)
let dialogRun = $state<string | null>(null)
function openTimeDialog(runId: string | null = null) {
dialogRun = runId
timeDialog = true
}
const stats = $derived.by(() => {
const finished = rows.filter((r) => r.best)
const total = finished.reduce((sum, r) => sum + (r.best?.duration ?? 0), 0)
const best = finished
.map((r) => r.best?.duration ?? 0)
.sort((a, b) => a - b)[0]
return { done: finished.length, of: rows.length, total, best }
})
</script>
<svelte:head><title>{rider ? fullName(rider) : 'Fahrer'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid}
<Card>
<CardContent class="py-16 text-center">
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Fahrer nicht gefunden</p>
<p class="text-sm text-muted-foreground mt-1">
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
</p>
<Button href="/dashboard/events" variant="outline" class="mt-4">
<ArrowLeft class="h-4 w-4 mr-2" />
Zur Eventliste
</Button>
</CardContent>
</Card>
{:else if event && rider}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a
href="/dashboard/events/{event.id}"
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
>
<ArrowLeft class="h-3 w-3" />
{event.name || 'Event'}
</a>
<div class="flex items-center gap-3">
<Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
<h1 class="text-3xl font-bold tracking-tight">
{fullName(rider) || 'Ohne Name'}
</h1>
</div>
</div>
<Button onclick={() => openTimeDialog()} disabled={rows.length === 0}>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Runs</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Bestzeit</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold tabular-nums">
{stats.best != null ? formatDuration(stats.best) : '—'}
</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Gesamtzeit</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold tabular-nums">
{stats.done > 0 ? formatDuration(stats.total) : '—'}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Timer class="h-4 w-4 text-primary" />
Runs ({rows.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if rows.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
Für dieses Event gibt es noch keine Runs.
</p>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Run</Table.Head>
<Table.Head class="w-24">Status</Table.Head>
<Table.Head class="text-right w-28">Zeit</Table.Head>
<Table.Head class="text-right w-20">Platz</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.run.id)}
<Table.Row
class="cursor-pointer"
onclick={() => goto(`/dashboard/events/${event.id}/runs/${row.run.id}`)}
>
<Table.Cell class="font-medium">
<a
href="/dashboard/events/{event.id}/runs/{row.run.id}"
class="hover:underline"
>
{row.run.description || 'Run'}
</a>
</Table.Cell>
<Table.Cell>
{#if row.active}
<Badge>Läuft</Badge>
{:else if row.best}
<Badge variant="secondary">Beendet</Badge>
{:else if row.others.length > 0}
<Badge variant="outline">
{TIME_STATUS_LABEL[row.others[0].status] ?? row.others[0].status}
</Badge>
{:else}
<span class="text-muted-foreground text-sm"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{#if row.active}
<span class="font-semibold">{row.active.formattedTime}</span>
{:else if row.best}
{row.best.formattedTime}
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if row.placement}
<Badge variant={row.placement <= 3 ? 'default' : 'secondary'} class="w-7 justify-center">
{row.placement}
</Badge>
{:else}
<span class="text-muted-foreground text-sm"></span>
{/if}
</Table.Cell>
<Table.Cell>
<ChevronRight class="h-4 w-4 text-muted-foreground" />
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
</div>
{/if}
<TimeDialog
bind:open={timeDialog}
eventId={event?.id ?? ''}
riderId={rider?.id ?? null}
runId={dialogRun}
/>

View file

@ -0,0 +1,245 @@
<script lang="ts">
/**
* Run-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
* laufenden zählen in Echtzeit mit. Die Tabelle zeigt nur an — gestartet
* und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
* eine gestoppte Zeit lässt sich nicht wieder aufnehmen.
*/
import { page } from '$app/state'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time'
import TimeDialog from '$lib/components/TimeDialog.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as Table from '@/components/ui/table'
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const clock = ticker()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const run = $derived(page.params.runId ? runs.getById(page.params.runId) : undefined)
// Ein Run gehört genau zu einem Event; passt die URL nicht zusammen, ist
// sie von Hand zusammengebaut oder veraltet.
const valid = $derived(!!event && !!run && run.event === event.id)
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
/**
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
* steht oben ohne Platz, darunter die Rangliste. So sieht man beides auf
* einen Blick, statt zwischen zwei Karten zu springen.
*/
const rows = $derived([
...running.map((time) => ({ time, place: null as number | null })),
...leaderboard.map((time, i) => ({ time, place: i + 1 })),
])
function rider(id: string | undefined) {
return id ? riders.getById(id) : undefined
}
function riderPath(riderId: string) {
return `/dashboard/events/${event?.id}/riders/${riderId}`
}
// --- Zeitnahme --------------------------------------------------------
// Start und Stopp laufen ausschließlich über TimeDialog; der Run ist
// dabei vorbelegt, den Fahrer wählt man dort.
let timeDialog = $state(false)
// --- Export ------------------------------------------------------------
function exportCsv() {
if (leaderboard.length === 0) return
const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)']]
leaderboard.forEach((t, i) => {
const r = rider(t.rider)
rows.push([
String(i + 1),
r?.number ?? '',
r ? fullName(r) : '',
t.formattedTime ?? '',
String(t.correction ?? 0),
])
})
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${safeName}-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
</script>
<svelte:head><title>{run?.description ?? 'Run'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid}
<Card>
<CardContent class="py-16 text-center">
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Run nicht gefunden</p>
<p class="text-sm text-muted-foreground mt-1">
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
</p>
<Button href="/dashboard/events" variant="outline" class="mt-4">
<ArrowLeft class="h-4 w-4 mr-2" />
Zur Eventliste
</Button>
</CardContent>
</Card>
{:else if event && run}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a
href="/dashboard/events/{event.id}"
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
>
<ArrowLeft class="h-3 w-3" />
{event.name || 'Event'}
</a>
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
<p class="mt-1 text-muted-foreground">
{running.length} laufend · {leaderboard.length} gewertet · {eventRiders.length} Fahrer im Event
</p>
</div>
<div class="flex items-center gap-2">
{#if leaderboard.length > 0}
<Button variant="outline" onclick={exportCsv}>
<Download class="h-4 w-4 mr-2" />
CSV
</Button>
{/if}
<Button onclick={() => (timeDialog = true)} disabled={eventRiders.length === 0}>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
</div>
{#if eventRiders.length === 0}
<Card>
<CardContent class="py-6 text-sm text-muted-foreground">
Für dieses Event gibt es noch keine Fahrer.
<a href="/dashboard/events/{event.id}" class="underline">Jetzt anlegen</a>
</CardContent>
</Card>
{/if}
<!-- Zeiten: laufende oben, darunter die Wertung -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" />
Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if rows.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
Noch keine Zeit. Über „Zeitnahme starten" geht es los.
</p>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-16">Platz</Table.Head>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head class="text-right">Zeit</Table.Head>
<Table.Head class="text-right w-24">Korrektur</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.time.id)}
{@const t = row.time}
{@const r = rider(t.rider)}
{@const live = row.place === null}
<Table.Row class={live ? 'bg-primary/5' : undefined}>
<Table.Cell>
{#if live}
<Badge variant="outline" class="text-primary border-primary">läuft</Badge>
{:else}
<Badge variant={row.place! <= 3 ? 'default' : 'secondary'} class="w-7 justify-center">
{row.place}
</Badge>
{/if}
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{r?.number ?? '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
{:else}
{/if}
</Table.Cell>
<Table.Cell class="text-right tabular-nums {live ? 'font-semibold' : ''}">
{t.formattedTime}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
{#if unranked.length > 0}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Flag class="h-4 w-4 text-muted-foreground" />
Ohne Wertung ({unranked.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
<ul class="divide-y">
{#each unranked as t (t.id)}
{@const r = rider(t.rider)}
<li class="px-6 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<Badge variant="outline">{r?.number ?? '—'}</Badge>
<span class="truncate">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
{:else}
{/if}
</span>
</div>
<Badge variant="secondary">{TIME_STATUS_LABEL[t.status] ?? t.status}</Badge>
</li>
{/each}
</ul>
</CardContent>
</Card>
{/if}
</div>
{/if}
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} runId={run?.id ?? null} />

View file

@ -1,203 +0,0 @@
<script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import * as Select from '@/components/ui/select'
import * as Table from '@/components/ui/table'
import { Trophy, Download, Flag, Medal } from 'lucide-svelte'
import { onMount } from 'svelte'
import { page } from '$app/state'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
let selectedEventId = $state<string>('')
let selectedRunId = $state<string>('')
onMount(() => {
const fromUrl = page.url.searchParams.get('event')
if (fromUrl) selectedEventId = fromUrl
else if (events.scoped.length > 0) selectedEventId = events.scoped[0].id
})
const availableRuns = $derived(
selectedEventId ? runs.getByEvent(selectedEventId) : [],
)
$effect(() => {
if (selectedEventId && availableRuns.length > 0) {
if (!availableRuns.some((r) => r.id === selectedRunId)) {
selectedRunId = availableRuns[0].id
}
} else {
selectedRunId = ''
}
})
const leaderboard = $derived(selectedRunId ? times.getLeaderboard(selectedRunId) : [])
const podium = $derived(leaderboard.slice(0, 3))
function getRider(id: string | undefined) {
return id ? riders.getById(id) : undefined
}
function exportCsv() {
if (leaderboard.length === 0) return
const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)']]
leaderboard.forEach((t, i) => {
const r = getRider(t.rider)
rows.push([
String(i + 1),
r?.number ?? '',
r ? fullName(r) : '',
t.formattedTime ?? '',
String(t.correction ?? 0),
])
})
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
const run = runs.getById(selectedRunId)
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${safeName}-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
const selectedEventName = $derived(
selectedEventId ? events.getById(selectedEventId)?.name ?? '—' : 'Event wählen…',
)
const selectedRunName = $derived(
selectedRunId ? runs.getById(selectedRunId)?.description ?? '—' : 'Run wählen…',
)
</script>
<svelte:head><title>Ergebnisse</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Ergebnisse</h1>
<p class="mt-1 text-muted-foreground">Zeiten und Rankings</p>
</div>
{#if leaderboard.length > 0}
<Button variant="outline" onclick={exportCsv}>
<Download class="h-4 w-4 mr-2" />
CSV exportieren
</Button>
{/if}
</div>
<Card>
<CardContent class="py-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>Event</Label>
<Select.Root type="single" bind:value={selectedEventId}>
<Select.Trigger>{selectedEventName}</Select.Trigger>
<Select.Content>
{#each events.scoped as e}
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Run</Label>
<Select.Root type="single" bind:value={selectedRunId} disabled={availableRuns.length === 0}>
<Select.Trigger>{selectedRunName}</Select.Trigger>
<Select.Content>
{#each availableRuns as r}
<Select.Item value={r.id}>{r.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
</CardContent>
</Card>
{#if !selectedRunId}
<Card>
<CardContent class="py-16 text-center">
<Trophy class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Wähle Event und Run</p>
<p class="text-sm text-muted-foreground mt-1">Um Ergebnisse anzuzeigen, oben auswählen.</p>
</CardContent>
</Card>
{:else if leaderboard.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Flag class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Noch keine Ergebnisse</p>
<p class="text-sm text-muted-foreground mt-1">Für diesen Run wurden noch keine Zeiten erfasst.</p>
</CardContent>
</Card>
{:else}
{#if podium.length > 0}
<div class="grid gap-4 md:grid-cols-3">
{#each podium as t, i}
{@const r = getRider(t.rider)}
{#if r}
<Card class={i === 0 ? 'border-primary md:order-2' : i === 1 ? 'md:order-1' : 'md:order-3'}>
<CardContent class="py-6 text-center">
<Medal class={`h-8 w-8 mx-auto mb-2 ${i === 0 ? 'text-primary' : 'text-muted-foreground'}`} />
<p class="text-sm text-muted-foreground">Platz {i + 1}</p>
<p class="text-lg font-semibold mt-1">#{r.number} {fullName(r)}</p>
<p class="font-mono text-2xl font-bold mt-2 tabular-nums">{t.formattedTime}</p>
</CardContent>
</Card>
{/if}
{/each}
</div>
{/if}
<Card>
<CardHeader>
<CardTitle>Vollständige Rangliste</CardTitle>
</CardHeader>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-16">Platz</Table.Head>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head class="text-right">Zeit</Table.Head>
<Table.Head class="text-right w-24">Korrektur</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each leaderboard as t, i (t.id)}
{@const r = getRider(t.rider)}
<Table.Row>
<Table.Cell>
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-7 justify-center">
{i + 1}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge variant="outline" class="font-mono">{r?.number ?? '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">{r ? fullName(r) : '—'}</Table.Cell>
<Table.Cell class="text-right font-mono tabular-nums">{t.formattedTime}</Table.Cell>
<Table.Cell class="text-right font-mono text-muted-foreground tabular-nums">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>

View file

@ -0,0 +1,143 @@
<script lang="ts">
/**
* Fahrerverzeichnis des Teams — für alle sichtbar, für niemanden hier
* änderbar. Anlegen, Bearbeiten und Kontoverknüpfung liegen in den
* Einstellungen und damit bei der Teamleitung; serverseitig setzen das
* die create/update/deleteRule der Collection durch.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getTimeContext } from '$lib/stores/times.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 * as Table from '@/components/ui/table'
import { Search, Settings, UserCheck, Users } from 'lucide-svelte'
const teams = getTeamContext()
const riders = getRiderContext()
const events = getEventContext()
const times = getTimeContext()
let query = $state('')
const roster = $derived.by(() => {
const q = query.toLowerCase().trim()
if (!q) return riders.scoped
return riders.scoped.filter(
(r) =>
(r.number ?? '').toLowerCase().includes(q) ||
fullName(r).toLowerCase().includes(q),
)
})
$effect(() => {
teams.loadMembers()
})
function eventName(id: string | undefined) {
if (!id) return null
return events.getById(id)?.name ?? 'Unbekanntes Event'
}
/** Zur Fahrerseite geht es nur mit Event — Zeiten hängen daran. */
function riderPath(r: { id: string; event?: string }) {
return r.event ? `/dashboard/events/${r.event}/riders/${r.id}` : null
}
</script>
<svelte:head><title>Fahrer</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight">Fahrer</h1>
<p class="mt-1 text-muted-foreground">
{riders.scoped.length} im Team {teams.active?.name ?? ''}
</p>
</div>
{#if riders.canManage}
<Button variant="outline" href="/dashboard/settings/riders">
<Settings class="size-4 mr-2" />
Verwalten
</Button>
{/if}
</div>
<div class="relative max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input bind:value={query} placeholder="Nummer oder Name…" class="pl-9" />
</div>
{#if riders.scoped.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Fahrer</p>
<p class="text-sm text-muted-foreground mt-1">
{#if riders.canManage}
Leg sie in den <a href="/dashboard/settings/riders" class="underline">Einstellungen</a> an.
{:else}
Die Teamleitung legt Fahrer in den Einstellungen an.
{/if}
</p>
</CardContent>
</Card>
{:else if roster.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">Keine Treffer.</CardContent>
</Card>
{:else}
<Card>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head class="text-right w-24">Zeiten</Table.Head>
<Table.Head class="w-48">Konto</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each roster as r (r.id)}
{@const path = riderPath(r)}
<Table.Row>
<Table.Cell>
<Badge variant="outline">{r.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{#if path}
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
{:else}
{fullName(r) || '— ohne Name —'}
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{eventName(r.event) ?? '—'}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground text-sm">
{times.getByRider(r.id).length}
</Table.Cell>
<Table.Cell>
{#if r.user}
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
{teams.userLabel(r.user)}
</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>

View file

@ -0,0 +1,57 @@
<script lang="ts">
/**
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
* Der Reiter „Fahrer" erscheint nur für die Teamleitung — dort wird
* angelegt und verknüpft, was alle anderen nur unter „Fahrer" im
* Hauptmenü zu sehen bekommen.
*/
import { page } from '$app/state'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { Flag, User, Users, Bike } from 'lucide-svelte'
let { children } = $props()
const teams = getTeamContext()
const tabs = $derived([
{ href: '/dashboard/settings', label: 'Profil', icon: User },
{ href: '/dashboard/settings/team', label: 'Team', icon: Users },
...(teams.isAdmin()
? [{ href: '/dashboard/settings/riders', label: 'Fahrer', icon: Bike }]
: []),
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
])
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
// auf jedem Unterpfad mit aktiv.
function isActive(href: string) {
return href === '/dashboard/settings'
? page.url.pathname === href
: page.url.pathname.startsWith(href)
}
</script>
<svelte:head><title>Einstellungen</title></svelte:head>
<div class="space-y-6">
<h1 class="text-3xl font-bold tracking-tight">Einstellungen</h1>
<nav class="flex flex-wrap gap-1 border-b">
{#each tabs as tab (tab.href)}
{@const Icon = tab.icon}
<a
href={tab.href}
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
class:border-primary={isActive(tab.href)}
class:text-foreground={isActive(tab.href)}
class:border-transparent={!isActive(tab.href)}
class:text-muted-foreground={!isActive(tab.href)}
>
<Icon class="size-4" />
{tab.label}
</a>
{/each}
</nav>
{@render children?.()}
</div>

View file

@ -0,0 +1,190 @@
<script lang="ts">
/**
* Eigenes Profil. Die users-Collection erlaubt Änderungen nur am eigenen
* Datensatz (updateRule `id = @request.auth.id`) — was hier steht, kann
* also niemand für jemand anderen ändern.
*/
import { api, auth } from '$lib/stores/pocketbase.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte'
import { Badge } from '@/components/ui/badge'
import { KeyRound, User } from 'lucide-svelte'
let name = $state(auth.user?.name ?? '')
let avatarFile = $state<File | null>(null)
let savingProfile = $state(false)
let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null)
async function saveProfile() {
if (!auth.user) return
savingProfile = true
profileMessage = null
profileError = null
try {
// Mit Datei als FormData, sonst als einfaches Objekt — ein leeres
// avatar-Feld im FormData würde ein vorhandenes Bild löschen.
let data: FormData | Record<string, unknown>
if (avatarFile) {
const fd = new FormData()
fd.append('name', name.trim())
fd.append('avatar', avatarFile)
data = fd
} else {
data = { name: name.trim() }
}
await api.collection('users').update(auth.user.id, data)
await auth.refresh()
avatarFile = null
profileMessage = 'Profil gespeichert.'
} catch (e: any) {
profileError = e.message ?? 'Das Profil konnte nicht gespeichert werden.'
} finally {
savingProfile = false
}
}
let oldPassword = $state('')
let newPassword = $state('')
let confirmPassword = $state('')
let savingPassword = $state(false)
let passwordMessage = $state<string | null>(null)
let passwordError = $state<string | null>(null)
async function savePassword() {
if (!auth.user) return
if (newPassword.length < 8) {
passwordError = 'Das neue Passwort braucht mindestens 8 Zeichen.'
return
}
if (newPassword !== confirmPassword) {
passwordError = 'Die Wiederholung stimmt nicht überein.'
return
}
savingPassword = true
passwordMessage = null
passwordError = null
try {
await api.collection('users').update(auth.user.id, {
oldPassword,
password: newPassword,
passwordConfirm: confirmPassword,
})
// PocketBase entwertet mit dem Passwortwechsel alle Tokens; ohne
// neue Anmeldung wäre die Sitzung ab hier tot.
await api.collection('users').authWithPassword(auth.user.email as string, newPassword)
if (auth.cookie) {
document.cookie = api.authStore.exportToCookie({ httpOnly: false })
}
oldPassword = newPassword = confirmPassword = ''
passwordMessage = 'Passwort geändert.'
} catch (e: any) {
passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.'
} finally {
savingPassword = false
}
}
</script>
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<User class="size-4 text-primary" />
Konto
{#if auth.user?.superadmin}
<!-- Appweite Rolle; vergeben wird sie nur in PocketBase. -->
<Badge variant="secondary">Superadmin</Badge>
{/if}
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-4">
<Avatar user={auth.user} size={64} />
<div class="space-y-1">
<Label for="avatar">Bild</Label>
<input
id="avatar"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">
Ohne Bild stehen deine Initialen da.
</p>
</div>
</div>
<div class="space-y-2">
<Label for="profile-name">Name</Label>
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" />
</div>
<div class="space-y-2">
<Label for="profile-mail">E-Mail</Label>
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
<p class="text-xs text-muted-foreground">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase.
</p>
</div>
{#if profileError}
<p class="text-sm text-destructive">{profileError}</p>
{:else if profileMessage}
<p class="text-sm text-muted-foreground">{profileMessage}</p>
{/if}
<Button onclick={saveProfile} disabled={savingProfile}>
{savingProfile ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<KeyRound class="size-4 text-primary" />
Passwort ändern
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="pw-old">Aktuelles Passwort</Label>
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="pw-new">Neues Passwort</Label>
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
</div>
<div class="space-y-2">
<Label for="pw-confirm">Wiederholen</Label>
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
{#if passwordError}
<p class="text-sm text-destructive">{passwordError}</p>
{:else if passwordMessage}
<p class="text-sm text-muted-foreground">{passwordMessage}</p>
{/if}
<Button
onclick={savePassword}
disabled={savingPassword || !oldPassword || !newPassword}
>
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
</Button>
</CardContent>
</Card>
</div>

View file

@ -7,6 +7,8 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import FlagIcon from '$lib/components/FlagIcon.svelte'
import IconPicker from '$lib/components/IconPicker.svelte'
import { Plus, Pencil, Trash2, Sparkles } from 'lucide-svelte'
import type { TrailFlagsResponse } from '$lib/types'
@ -104,10 +106,9 @@
}
</script>
<div class="space-y-6">
<div class="space-y-4">
<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>
@ -149,7 +150,7 @@
{#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>
<FlagIcon name={flag.icon} color={flag.color} class="size-5" />
<span class="font-medium flex-1">{flag.label}</span>
<Badge variant={flag.severity === 'kritisch' ? 'destructive' : 'secondary'}>
{flag.severity}
@ -201,13 +202,8 @@
{/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>
<IconPicker id="flag-icon" bind:value={form.icon} color={form.color} />
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}

View file

@ -0,0 +1,385 @@
<script lang="ts">
/**
* Fahrerverwaltung — nur für die Teamleitung. Was alle sehen dürfen,
* steht unter „Fahrer" im Hauptmenü; angelegt, geändert und mit einem
* Login verknüpft wird ausschließlich hier.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.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 * as Select from '@/components/ui/select'
import * as Table from '@/components/ui/table'
import { KeyRound, Pencil, Plus, Trash2, Unlink, UserCheck } from 'lucide-svelte'
import type { RidersResponse } from '$lib/types'
const teams = getTeamContext()
const riders = getRiderContext()
const events = getEventContext()
const canManage = $derived(riders.canManage)
const roster = $derived(riders.scoped)
$effect(() => {
teams.loadMembers()
})
function eventName(id: string | undefined) {
if (!id) return null
return events.getById(id)?.name ?? 'Unbekanntes Event'
}
// --- Fahrer anlegen und ändern ---------------------------------------
let riderDialog = $state(false)
let editing = $state<RidersResponse | null>(null)
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
let riderError = $state<string | null>(null)
function openCreate() {
editing = null
form = { number: '', firstname: '', lastname: '', event: '' }
riderError = null
riderDialog = true
}
function openEdit(r: RidersResponse) {
editing = r
form = {
number: r.number ?? '',
firstname: r.firstname ?? '',
lastname: r.lastname ?? '',
event: r.event ?? '',
}
riderError = null
riderDialog = true
}
async function saveRider() {
if (!form.number.trim()) {
riderError = 'Die Startnummer ist Pflicht.'
return
}
// Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich
// den gemeinsamen Topf des Teams.
if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) {
riderError = `Startnummer ${form.number} ist dort bereits vergeben.`
return
}
riderError = null
try {
const data = {
number: form.number.trim(),
firstname: form.firstname.trim(),
lastname: form.lastname.trim(),
event: form.event,
}
if (editing) await riders.edit(editing.id, data)
else await riders.create(data)
riderDialog = false
} catch (e: any) {
riderError = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
}
}
// --- Login verknüpfen -------------------------------------------------
let loginDialog = $state(false)
let loginRider = $state<RidersResponse | null>(null)
let loginMode = $state<'link' | 'create'>('link')
let linkUserId = $state('')
let loginForm = $state({ email: '', password: '' })
let loginBusy = $state(false)
let loginError = $state<string | null>(null)
/** Teammitglieder, die noch an keinem anderen Fahrer hängen. */
const linkCandidates = $derived.by(() => {
const taken = new Set(
roster.filter((r) => r.user && r.id !== loginRider?.id).map((r) => r.user),
)
return (teams.active?.users ?? []).filter((id) => !taken.has(id))
})
function openLogin(r: RidersResponse) {
loginRider = r
loginMode = 'link'
linkUserId = r.user ?? ''
loginForm = { email: '', password: '' }
loginError = null
loginDialog = true
}
async function doLink() {
if (!loginRider || !linkUserId) return
loginBusy = true
loginError = null
try {
await riders.linkUser(loginRider.id, linkUserId)
loginDialog = false
} catch (e: any) {
loginError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
} finally {
loginBusy = false
}
}
async function doUnlink(r: RidersResponse) {
try {
await riders.linkUser(r.id, null)
} catch (e: any) {
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
}
}
async function doCreateLogin() {
if (!loginRider) return
if (!loginForm.email.trim()) {
loginError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (loginForm.password.length < 8) {
loginError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
loginBusy = true
loginError = null
try {
await riders.createLogin(loginRider, { ...loginForm })
loginDialog = false
} catch (e: any) {
loginError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
loginBusy = false
}
}
</script>
{#if !canManage}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Fahrer verwaltet, wer das Team führt. Die Fahrer deines Teams siehst du
unter <a href="/dashboard/riders" class="underline">Fahrer</a>.
</CardContent>
</Card>
{:else}
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
anzumelden.
</p>
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Fahrer anlegen
</Button>
</div>
{#if roster.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Noch keine Fahrer im Team.
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head>Konto</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each roster as r (r.id)}
<Table.Row>
<Table.Cell>
<Badge variant="outline">{r.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{fullName(r) || '— ohne Name —'}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{eventName(r.event) ?? '—'}
</Table.Cell>
<Table.Cell>
{#if r.user}
<span class="inline-flex items-center gap-2">
<Badge variant="secondary" class="gap-1">
<UserCheck class="size-3" />
kann sich einloggen
</Badge>
<span class="text-sm text-muted-foreground">
{teams.userLabel(r.user)}
</span>
</span>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">
{#if r.user}
<Button variant="ghost" size="icon" title="Verknüpfung lösen" onclick={() => doUnlink(r)}>
<Unlink class="size-4" />
</Button>
{:else}
<Button variant="ghost" size="icon" title="Konto verknüpfen" onclick={() => openLogin(r)}>
<KeyRound class="size-4" />
</Button>
{/if}
<Button variant="ghost" size="icon" title="Bearbeiten" onclick={() => openEdit(r)}>
<Pencil class="size-4" />
</Button>
<Button variant="ghost" size="icon" title="Löschen" onclick={() => riders.remove(r)}>
<Trash2 class="size-4 text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>
{/if}
<!-- Fahrer anlegen/bearbeiten -->
<Dialog.Root bind:open={riderDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="rider-number">Startnummer</Label>
<Input id="rider-number" bind:value={form.number} placeholder="42" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="rider-first">Vorname</Label>
<Input id="rider-first" bind:value={form.firstname} placeholder="Max" />
</div>
<div class="space-y-2">
<Label for="rider-last">Nachname</Label>
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
</div>
</div>
<div class="space-y-2">
<Label>Event</Label>
<Select.Root type="single" bind:value={form.event}>
<Select.Trigger class="w-full">
{eventName(form.event) ?? 'Keinem Event zugeordnet'}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Keinem Event zugeordnet</Select.Item>
{#each events.scoped as e (e.id)}
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im
Team-Verzeichnis.
</p>
</div>
{#if riderError}
<p class="text-sm text-destructive">{riderError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Konto verknüpfen oder anlegen -->
<Dialog.Root bind:open={loginDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : 'Fahrer'}</Dialog.Title>
<Dialog.Description>
Mit einem Konto kann sich die Person selbst anmelden.
</Dialog.Description>
</Dialog.Header>
<div class="flex gap-2">
<Button
variant={loginMode === 'link' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'link')}
>
Vorhandenes verknüpfen
</Button>
<Button
variant={loginMode === 'create' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'create')}
>
Neues anlegen
</Button>
</div>
<div class="space-y-4">
{#if loginMode === 'link'}
<div class="space-y-2">
<Label>Teammitglied</Label>
<Select.Root type="single" bind:value={linkUserId}>
<Select.Trigger class="w-full">
{linkUserId ? teams.userLabel(linkUserId) : 'Mitglied wählen…'}
</Select.Trigger>
<Select.Content>
{#each linkCandidates as id (id)}
<Select.Item value={id}>{teams.userLabel(id)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if linkCandidates.length === 0}
<p class="text-xs text-muted-foreground">
Alle Teammitglieder sind bereits einem Fahrer zugeordnet.
</p>
{/if}
</div>
{:else}
<div class="space-y-2">
<Label for="login-mail">E-Mail</Label>
<Input id="login-mail" type="email" bind:value={loginForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="login-pw">Passwort</Label>
<Input id="login-pw" type="password" bind:value={loginForm.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
sich unter „Profil" ändern.
</p>
</div>
{/if}
{#if loginError}
<p class="text-sm text-destructive">{loginError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (loginDialog = false)}>Abbrechen</Button>
{#if loginMode === 'link'}
<Button onclick={doLink} disabled={!linkUserId || loginBusy}>Verknüpfen</Button>
{:else}
<Button onclick={doCreateLogin} disabled={loginBusy}>
{loginBusy ? 'Wird angelegt …' : 'Konto anlegen'}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,347 @@
<script lang="ts">
/**
* Team-Verwaltung. Owner und Admins ändern hier die Team-Daten und die
* Mitglieder; alle anderen sehen dieselbe Liste ohne Knöpfe.
*/
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, 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 Avatar from '$lib/components/Avatar.svelte'
import { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
import type { TeamsResponse } from '$lib/types'
const teams = getTeamContext()
const flags = getTrailFlagContext()
const me = $derived(api.authStore.record?.id ?? null)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => {
for (const t of teams.records) teams.loadMembers(t)
})
// ---- Team anlegen ----
let createDialog = $state(false)
let createName = $state('')
let createError = $state<string | null>(null)
async function doCreate() {
if (!createName.trim()) return
createError = null
try {
const team = await teams.create(createName.trim())
// Ein Team ohne Flag-Typen kann zwar Marker aufnehmen, aber nichts
// einordnen. Deshalb kommt der Standardsatz gleich mit — änderbar
// und, wie alle Flag-Typen, allein Sache dieses Teams.
try {
await flags.seedDefaults(team.id)
} catch (e: any) {
createError =
'Das Team wurde angelegt, die Standard-Flag-Typen aber nicht: ' +
(e.message ?? 'unbekannter Fehler') +
' Du kannst sie unter „Flag-Typen" nachholen.'
return
}
createDialog = false
createName = ''
} catch (e: any) {
createError = e.message ?? 'Das Team konnte nicht angelegt werden.'
}
}
// ---- Team umbenennen ----
let renameDialog = $state(false)
let renameTeam = $state<TeamsResponse | null>(null)
let renameValue = $state('')
let renameError = $state<string | null>(null)
function openRename(t: TeamsResponse) {
renameTeam = t
renameValue = t.name
renameError = null
renameDialog = true
}
async function doRename() {
if (!renameTeam || !renameValue.trim()) return
renameError = null
try {
await teams.rename(renameTeam.id, renameValue.trim())
renameDialog = false
} catch (e: any) {
renameError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
}
}
// ---- Mitglied anlegen ----
// Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
// eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
// anzulegen deckt den Alltag ab — Vereinsmitglieder haben vorher keins.
let memberDialog = $state(false)
let memberTeam = $state<TeamsResponse | null>(null)
let memberForm = $state({ name: '', email: '', password: '' })
let memberBusy = $state(false)
let memberError = $state<string | null>(null)
function openMember(t: TeamsResponse) {
memberTeam = t
memberForm = { name: '', email: '', password: '' }
memberError = null
memberDialog = true
}
async function doCreateMember() {
if (!memberTeam) return
if (!memberForm.email.trim()) {
memberError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (memberForm.password.length < 8) {
memberError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
memberBusy = true
memberError = null
try {
await teams.createMember({ ...memberForm }, memberTeam.id)
memberDialog = false
} catch (e: any) {
memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
memberBusy = false
}
}
// ---- Aktionen ----
async function doLeave(t: TeamsResponse) {
if (!confirm(`Team „${t.name}" wirklich verlassen?`)) return
try {
await teams.leave(t.id)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doRemoveMember(t: TeamsResponse, userId: string) {
if (userId === t.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Mitglied wirklich entfernen?')) return
try {
await teams.removeMember(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
Trails du siehst.
</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="size-4 mr-2" />
Neues Team
</Button>
</div>
{#if teams.records.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="size-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch in keinem Team</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Erstelle dein erstes Team.</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="size-4 mr-2" />
Team erstellen
</Button>
</CardContent>
</Card>
{:else}
{#each teams.records as t (t.id)}
{@const iAmOwner = t.owner === me}
{@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')}
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle class="flex items-center gap-2">
{t.name}
{#if t.id === teams.activeId}
<Badge>Aktiv</Badge>
{/if}
</CardTitle>
<CardDescription>
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if t.id !== teams.activeId}
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
Aktivieren
</Button>
{/if}
{#if iAmAdmin}
<Button variant="outline" size="sm" onclick={() => openRename(t)}>
Umbenennen
</Button>
<Button variant="outline" size="sm" onclick={() => openMember(t)}>
<UserPlus class="size-4 mr-1" />
Mitglied
</Button>
{/if}
{#if !iAmOwner}
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
<LogOut class="size-4 mr-1" />
Verlassen
</Button>
{:else}
<Button variant="ghost" size="icon" title="Team löschen" onclick={() => teams.remove(t)}>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent>
<ul class="divide-y border-t">
{#each (t.users ?? []) as userId (userId)}
{@const isOwner = userId === t.owner}
{@const isAdm = (t.admins ?? []).includes(userId)}
<li class="py-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<Avatar user={teams.users[userId]} size={28} />
<span class="font-medium truncate">{teams.userLabel(userId)}</span>
{#if isOwner}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if isAdm}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{/if}
{#if userId === me}
<Badge variant="outline">Du</Badge>
{/if}
</div>
{#if iAmOwner && !isOwner}
<div class="flex items-center gap-1 shrink-0">
{#if isAdm}
<Button variant="ghost" size="sm" onclick={() => teams.demoteFromAdmin(t.id, userId)}>
Admin entziehen
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => teams.promoteToAdmin(t.id, userId)}>
Zu Admin
</Button>
{/if}
<Button variant="ghost" size="icon" title="Entfernen" onclick={() => doRemoveMember(t, userId)}>
<UserMinus class="size-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
</CardContent>
</Card>
{/each}
{/if}
</div>
<!-- Team anlegen -->
<Dialog.Root bind:open={createDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-name">Name</Label>
<Input id="team-name" bind:value={createName} placeholder="Mein Team" />
</div>
{#if createError}
<p class="text-sm text-destructive">{createError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (createDialog = false)}>Abbrechen</Button>
<Button onclick={doCreate} disabled={!createName.trim()}>Erstellen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Team umbenennen -->
<Dialog.Root bind:open={renameDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Team umbenennen</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="team-rename">Name</Label>
<Input id="team-rename" bind:value={renameValue} />
</div>
{#if renameError}
<p class="text-sm text-destructive">{renameError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (renameDialog = false)}>Abbrechen</Button>
<Button onclick={doRename} disabled={!renameValue.trim()}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Mitglied anlegen -->
<Dialog.Root bind:open={memberDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Mitglied anlegen</Dialog.Title>
<Dialog.Description>
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="member-name">Name</Label>
<Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
</div>
<div class="space-y-2">
<Label for="member-mail">E-Mail</Label>
<Input id="member-mail" type="email" bind:value={memberForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="member-pw">Passwort</Label>
<Input id="member-pw" type="password" bind:value={memberForm.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Gib es der Person weiter; sie kann es unter „Profil" ändern.
</p>
</div>
{#if memberError}
<p class="text-sm text-destructive">{memberError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
<Button onclick={doCreateMember} disabled={memberBusy}>
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,341 +0,0 @@
<script lang="ts">
import { auth, api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, 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 { Crown, Plus, Trash2, UserMinus, UserPlus, Users, Shield, LogOut } from 'lucide-svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const me = $derived(api.authStore.record?.id ?? null)
// Member-Cache: User-Info pro ID
let userCache = $state<Record<string, UsersResponse>>({})
async function loadUser(id: string) {
if (userCache[id]) return userCache[id]
try {
const u = await api.collection('users').getOne(id, { requestKey: null })
userCache = { ...userCache, [id]: u }
return u
} catch {
return null
}
}
$effect(() => {
// Bei Team-Änderung alle Mitglieder vorladen
for (const t of teams.records) {
const ids = [...(t.users ?? []), t.owner].filter(Boolean) as string[]
for (const id of ids) {
if (!userCache[id]) loadUser(id)
}
}
})
function userLabel(id: string) {
const u = userCache[id]
if (!u) return id.slice(0, 6) + '…'
return u.name || u.email || u.id.slice(0, 6) + '…'
}
// ---- Team-Create Dialog ----
let createDialog = $state(false)
let createName = $state('')
async function doCreate() {
if (!createName.trim()) return
try {
await teams.create(createName.trim())
createDialog = false
createName = ''
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
// ---- Member-Invite Dialog ----
let inviteDialog = $state(false)
let inviteTargetTeam = $state<TeamsResponse | null>(null)
let inviteEmail = $state('')
let inviteSearching = $state(false)
let inviteFound = $state<UsersResponse | null>(null)
let inviteError = $state<string | null>(null)
function openInvite(t: TeamsResponse) {
inviteTargetTeam = t
inviteEmail = ''
inviteFound = null
inviteError = null
inviteDialog = true
}
async function doSearch() {
if (!inviteEmail.trim()) return
inviteSearching = true
inviteError = null
inviteFound = null
try {
const u = await teams.findUserByEmail(inviteEmail.trim())
if (u) {
inviteFound = u
userCache = { ...userCache, [u.id]: u }
} else {
inviteError = `Kein User mit "${inviteEmail}" gefunden`
}
} catch (e: any) {
inviteError = e.message
} finally {
inviteSearching = false
}
}
async function doInvite() {
if (!inviteTargetTeam || !inviteFound) return
try {
await teams.addMember(inviteTargetTeam.id, inviteFound.id)
inviteDialog = false
} catch (e: any) {
inviteError = e.message
}
}
// ---- Aktionen ----
async function doLeave(t: TeamsResponse) {
if (!confirm(`Team "${t.name}" wirklich verlassen?`)) return
try {
await teams.leave(t.id)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doRemoveMember(t: TeamsResponse, userId: string) {
if (userId === t.owner) return alert('Owner kann nicht entfernt werden')
if (!confirm(`Mitglied wirklich entfernen?`)) return
try {
await teams.removeMember(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doPromote(t: TeamsResponse, userId: string) {
try {
await teams.promoteToAdmin(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function doDemote(t: TeamsResponse, userId: string) {
try {
await teams.demoteFromAdmin(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
function isAdmin(t: TeamsResponse, userId: string) {
return (t.admins ?? []).includes(userId)
}
</script>
<svelte:head><title>Teams Zeitnahme</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Teams</h1>
<p class="mt-1 text-muted-foreground">Deine Teams und Mitglieder verwalten</p>
</div>
<Button onclick={() => (createDialog = true)}>
<Plus class="h-4 w-4 mr-2" />
Neues Team
</Button>
</div>
{#if teams.records.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Noch in keinem Team</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Erstelle dein erstes Team.</p>
<Button onclick={() => (createDialog = true)}>
<Plus class="h-4 w-4 mr-2" />
Team erstellen
</Button>
</CardContent>
</Card>
{:else}
<div class="space-y-4">
{#each teams.records as t (t.id)}
{@const iAmOwner = t.owner === me}
{@const iAmAdmin = iAmOwner || (t.admins ?? []).includes(me ?? '')}
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<div>
<CardTitle class="flex items-center gap-2">
{t.name}
{#if t.id === teams.activeId}
<Badge variant="default">Aktiv</Badge>
{/if}
</CardTitle>
<CardDescription>
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
</CardDescription>
</div>
<div class="flex items-center gap-2">
{#if t.id !== teams.activeId}
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
Aktivieren
</Button>
{/if}
{#if iAmAdmin}
<Button variant="outline" size="sm" onclick={() => openInvite(t)}>
<UserPlus class="h-4 w-4 mr-1" />
Mitglied
</Button>
{/if}
{#if !iAmOwner}
<Button variant="ghost" size="sm" onclick={() => doLeave(t)}>
<LogOut class="h-4 w-4 mr-1" />
Verlassen
</Button>
{/if}
{#if iAmOwner}
<Button variant="ghost" size="icon" onclick={() => teams.remove(t)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent>
<ul class="divide-y border-t">
{#each (t.users ?? []) as userId}
{@const isOwner = userId === t.owner}
{@const isAdm = isAdmin(t, userId)}
<li class="py-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{userLabel(userId)}</span>
{#if isOwner}
<Badge variant="default" class="gap-1">
<Crown class="h-3 w-3" />
Owner
</Badge>
{:else if isAdm}
<Badge variant="secondary" class="gap-1">
<Shield class="h-3 w-3" />
Admin
</Badge>
{/if}
{#if userId === me}
<Badge variant="outline">Du</Badge>
{/if}
</div>
{#if iAmOwner && !isOwner}
<div class="flex items-center gap-1">
{#if isAdm}
<Button variant="ghost" size="sm" onclick={() => doDemote(t, userId)}>
Admin entziehen
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => doPromote(t, userId)}>
Zu Admin
</Button>
{/if}
<Button variant="ghost" size="icon" onclick={() => doRemoveMember(t, userId)}>
<UserMinus class="h-4 w-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
</CardContent>
</Card>
{/each}
</div>
{/if}
</div>
<!-- Create-Dialog -->
<Dialog.Root bind:open={createDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Neues Team</Dialog.Title>
<Dialog.Description>Du wirst automatisch Owner.</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault()
doCreate()
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="team-name">Name *</Label>
<Input id="team-name" bind:value={createName} placeholder="Mein Team" required />
</div>
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (createDialog = false)}>Abbrechen</Button>
<Button type="submit">Erstellen</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Invite-Dialog -->
<Dialog.Root bind:open={inviteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Mitglied hinzufügen</Dialog.Title>
<Dialog.Description>
Suche einen registrierten User über seine E-Mail-Adresse.
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault()
doSearch()
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="invite-email">E-Mail</Label>
<div class="flex gap-2">
<Input id="invite-email" type="email" bind:value={inviteEmail} placeholder="user@example.com" />
<Button type="submit" variant="outline" disabled={inviteSearching}>
{inviteSearching ? 'Suche…' : 'Suchen'}
</Button>
</div>
</div>
{#if inviteError}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{inviteError}
</div>
{/if}
{#if inviteFound}
<div class="rounded-md border bg-accent/40 p-3">
<p class="font-medium">{inviteFound.name || inviteFound.email}</p>
{#if inviteFound.email}
<p class="text-sm text-muted-foreground">{inviteFound.email}</p>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (inviteDialog = false)}>Abbrechen</Button>
<Button type="button" disabled={!inviteFound} onclick={doInvite}>Hinzufügen</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,281 +0,0 @@
<script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.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 Select from '@/components/ui/select'
import { Play, Square, Clock, Trophy, Search, Timer } from 'lucide-svelte'
import { onMount, onDestroy } from 'svelte'
import { page } from '$app/state'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
let selectedEventId = $state<string>('')
let selectedRunId = $state<string>('')
let searchQuery = $state('')
let tick = $state(0)
onMount(() => {
const fromUrl = page.url.searchParams.get('event')
if (fromUrl) {
selectedEventId = fromUrl
} else if (events.active.length > 0) {
selectedEventId = events.active[0].id
} else if (events.scoped.length > 0) {
selectedEventId = events.scoped[0].id
}
const interval = setInterval(() => (tick++), 100)
return () => clearInterval(interval)
})
const availableRuns = $derived(
selectedEventId ? runs.getByEvent(selectedEventId) : [],
)
$effect(() => {
// Wenn Event wechselt und der bisherige Run nicht dazugehört: ersten Run wählen
if (selectedEventId && availableRuns.length > 0) {
if (!availableRuns.some((r) => r.id === selectedRunId)) {
selectedRunId = availableRuns[0].id
}
} else {
selectedRunId = ''
}
})
const eventRiders = $derived(
selectedEventId ? riders.getByEvent(selectedEventId) : [],
)
const filteredRiders = $derived(() => {
const q = searchQuery.toLowerCase().trim()
if (!q) return eventRiders
return eventRiders.filter((r) => {
return (
(r.number ?? '').toLowerCase().includes(q) ||
fullName(r).toLowerCase().includes(q)
)
})
})
const runTimes = $derived(selectedRunId ? times.getByRun(selectedRunId) : [])
const runningTimes = $derived(runTimes.filter((t) => t.status === 'active'))
const leaderboard = $derived(selectedRunId ? times.getLeaderboard(selectedRunId).slice(0, 5) : [])
function getElapsed(startISO: string | undefined): string {
if (!startISO) return '0:00.0'
const start = new Date(startISO).getTime()
const elapsed = (Date.now() - start) / 1000
const m = Math.floor(elapsed / 60)
const s = (elapsed % 60).toFixed(1)
return `${m}:${s.padStart(4, '0')}`
}
function getRider(id: string | undefined) {
return id ? riders.getById(id) : undefined
}
async function start(riderId: string) {
if (!selectedRunId) return alert('Bitte Run auswählen')
if (times.isRiderActive(selectedRunId, riderId)) return alert('Dieser Fahrer läuft bereits!')
try {
await times.start(selectedRunId, riderId)
} catch (e: any) {
alert(`Start fehlgeschlagen: ${e.message}`)
}
}
async function stop(timeId: string) {
try {
await times.stop(timeId)
} catch (e: any) {
alert(`Stopp fehlgeschlagen: ${e.message}`)
}
}
const selectedEventName = $derived(
selectedEventId ? events.getById(selectedEventId)?.name ?? 'Event wählen…' : 'Event wählen…',
)
const selectedRunName = $derived(
selectedRunId ? runs.getById(selectedRunId)?.description ?? 'Run wählen…' : 'Run wählen…',
)
</script>
<svelte:head><title>Zeitnahme</title></svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold tracking-tight">Zeitnahme</h1>
<p class="mt-1 text-muted-foreground">Zeiten für Fahrer starten und stoppen</p>
</div>
<Card>
<CardContent class="py-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>Event</Label>
<Select.Root type="single" bind:value={selectedEventId}>
<Select.Trigger>{selectedEventName}</Select.Trigger>
<Select.Content>
{#each events.scoped as e}
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Run</Label>
<Select.Root type="single" bind:value={selectedRunId} disabled={availableRuns.length === 0}>
<Select.Trigger>{selectedRunName}</Select.Trigger>
<Select.Content>
{#each availableRuns as r}
<Select.Item value={r.id}>{r.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if selectedEventId && availableRuns.length === 0}
<p class="text-xs text-muted-foreground">
Keine Runs für dieses Event. Über PocketBase Runs anlegen.
</p>
{/if}
</div>
</div>
</CardContent>
</Card>
{#if !selectedEventId || !selectedRunId}
<Card>
<CardContent class="py-16 text-center">
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Wähle ein Event und einen Run</p>
<p class="text-sm text-muted-foreground mt-1">
Um mit der Zeitnahme zu beginnen, oben Event und Run auswählen.
</p>
</CardContent>
</Card>
{:else}
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-4">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input bind:value={searchQuery} placeholder="Fahrer suchen (Nummer, Name)…" class="pl-9" />
</div>
<Card>
<CardContent class="p-0">
{#if filteredRiders().length === 0}
<div class="py-12 text-center text-muted-foreground">
{searchQuery ? 'Keine Treffer' : 'Keine Fahrer für dieses Event'}
</div>
{:else}
<ul class="divide-y">
{#each filteredRiders() as rider (rider.id)}
{@const active = times.isRiderActive(selectedRunId, rider.id)}
{@const activeTime = active ? times.getActiveTime(selectedRunId, rider.id) : undefined}
<li class="p-4 flex items-center justify-between hover:bg-accent/40">
<div class="flex items-center gap-4">
<Badge variant="outline" class="font-mono text-base px-3 py-1">
{rider.number || '—'}
</Badge>
<div>
<p class="font-medium">{fullName(rider) || '—'}</p>
</div>
</div>
<div class="flex items-center gap-3">
{#if active && activeTime}
{#key tick}
<div class="font-mono text-2xl font-semibold tabular-nums">
{getElapsed(activeTime.start)}
</div>
{/key}
<Button variant="destructive" onclick={() => stop(activeTime.id)}>
<Square class="h-4 w-4 mr-2" />
Stopp
</Button>
{:else}
<Button onclick={() => start(rider.id)}>
<Play class="h-4 w-4 mr-2" />
Start
</Button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
</div>
<div class="space-y-4">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Clock class="h-4 w-4 text-primary" />
Laufende Zeiten ({runningTimes.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if runningTimes.length === 0}
<p class="px-4 py-6 text-sm text-center text-muted-foreground">Keine laufenden Zeiten</p>
{:else}
<ul class="divide-y">
{#each runningTimes as t}
{@const r = getRider(t.rider)}
{#if r}
{#key tick}
<li class="px-4 py-3">
<p class="text-sm text-muted-foreground">#{r.number} {fullName(r)}</p>
<p class="font-mono text-xl font-semibold tabular-nums">{getElapsed(t.start)}</p>
</li>
{/key}
{/if}
{/each}
</ul>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" />
Top 5
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if leaderboard.length === 0}
<p class="px-4 py-6 text-sm text-center text-muted-foreground">Noch keine Ergebnisse</p>
{:else}
<ol class="divide-y">
{#each leaderboard as t, i}
{@const r = getRider(t.rider)}
{#if r}
<li class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-6 justify-center">
{i + 1}
</Badge>
<span class="text-sm">#{r.number} {fullName(r)}</span>
</div>
<span class="font-mono text-sm font-semibold tabular-nums">{t.formattedTime}</span>
</li>
{/if}
{/each}
</ol>
{/if}
</CardContent>
</Card>
</div>
</div>
{/if}
</div>

View file

@ -4,6 +4,7 @@
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import { trailStatus } from '$lib/trailStatus'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -22,18 +23,6 @@
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
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
/** Aktive Version eines Trails, für Kennzahlen und Kartenlinie */
@ -119,14 +108,19 @@
{#each trails.scoped as trail (trail.id)}
{@const version = currentVersion(trail.id)}
{@const open = markers.openByTrail(trail.id).length}
{@const status = trailStatus(trail.status)}
<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>
<span
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
style="color: {status.color}; border-color: {status.color}"
>
<span class="size-1.5 rounded-full" style:background={status.color}></span>
{status.label}
</span>
</div>
</CardHeader>
<CardContent class="space-y-3">
@ -135,6 +129,7 @@
geojson={version.geojson}
bounds={version.bounds}
status={trail.status}
controls={false}
height="140px"
/>
{:else}

View file

@ -9,21 +9,28 @@
import { getTeamContext } from '$lib/stores/teams.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
import FlagIcon from '$lib/components/FlagIcon.svelte'
import IconPicker from '$lib/components/IconPicker.svelte'
import Avatar from '$lib/components/Avatar.svelte'
import { tooltip } from '$lib/stores/app.svelte'
import { positionAtDistance } from '$lib/gpx'
import { app } from '$lib/stores/app.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 DropdownMenu from '@/components/ui/dropdown-menu'
import * as Select from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
import {
ArrowLeft, Upload, MapPin, MessageSquare, History,
Check, Trash2, Download, Plus, AlertTriangle,
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
Check, Trash2, Download, Plus, Ruler, TrendingUp,
} from 'lucide-svelte'
import DOMPurify from 'dompurify'
import { browser } from '$app/environment'
import type { TrailCommentsResponse, TrailVersionsResponse } from '$lib/types'
import type { TrailCommentsResponse, TrailVersionsResponse, UsersResponse } from '$lib/types'
// Generics der JSON-Felder, wie in trails/+page.svelte — die
// Rohdaten kommen ungetypt aus PocketBase zurück.
@ -65,23 +72,54 @@
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
// Für die Karte aufbereitete Marker
const NEUTRAL = '#64748b'
/**
* Beschriftung, Icon und Farbe eines Markers. Ein eigener Text oder ein
* eigenes Icon am Marker geht vor: Beim Setzen darf der Typ nur den
* Vorschlag liefern, nicht das letzte Wort haben.
*/
function markerLook(m: { flag?: string; label?: string; icon?: string }) {
const flag = m.flag ? flags.getById(m.flag) : undefined
return {
label: m.label?.trim() || flag?.label || 'Marker',
icon: m.icon?.trim() || flag?.icon,
color: flag?.color ?? NEUTRAL,
}
}
// Für die Karte aufbereitete Marker — inklusive der Angaben fürs Popup
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
// und keine Datumslogik braucht.
const mapMarkers = $derived(
trailMarkers.map((m) => {
const flag = flags.getById(m.flag)
const look = markerLook(m)
return {
id: m.id,
lat: m.lat,
lng: m.lng,
color: flag?.color ?? '#64748b',
color: look.color,
resolved: !!m.resolved,
label: flag?.label ?? 'Marker',
label: look.label,
icon: look.icon,
note: m.note,
when: formatDate(m.created),
who: markers.authorName(m),
}
}),
)
let tab = $state<'marker' | 'kommentare' | 'versionen'>('marker')
let highlight = $state<number | null>(null)
// Versionen sind Archiv, kein Alltag — deshalb zugeklappt.
let showVersions = $state(false)
const statusInfo = $derived(trailStatus(trail?.status))
// Wer den Status gemeldet hat, steht nur als ID im Datensatz.
$effect(() => {
if (trail?.status_by) teams.loadUser(trail.status_by)
})
// --- GPX-Upload ------------------------------------------------------
let uploadDialog = $state(false)
@ -112,26 +150,88 @@
}
// --- Marker setzen ---------------------------------------------------
let placing = $state(false)
// Die Koordinaten kommen bereits auf die Linie projiziert aus der Karte;
// abseits des Trails lässt sich gar nicht erst klicken.
let markerDialog = $state(false)
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
let markerFlag = $state('')
let markerDistance = $state<number | null>(null)
// Leerer Wert = eigener Typ. Als erste Zeile im Dropdown, damit eine
// Meldung nie daran scheitert, dass es den passenden Typ noch nicht gibt.
const CUSTOM = ''
let markerFlag = $state(CUSTOM)
let markerLabel = $state('')
let markerIcon = $state('')
let markerNote = $state('')
let markerError = $state<string | null>(null)
function onPlace(coords: { lat: number; lng: number }) {
markerCoords = coords
markerFlag = flags.scoped[0]?.id ?? ''
// Ohne Flag-Typen geht es trotzdem: Dann ist jede Meldung eine eigene.
const canPlace = $derived(!!trail)
const selectedFlag = $derived(markerFlag ? flags.getById(markerFlag) : undefined)
const markerColor = $derived(selectedFlag?.color ?? NEUTRAL)
function onPlace(p: { lat: number; lng: number; distance: number | null }) {
if (!canPlace) return
markerCoords = { lat: p.lat, lng: p.lng }
markerDistance = p.distance
markerFlag = flags.scoped[0]?.id ?? CUSTOM
markerLabel = ''
markerIcon = flags.scoped[0]?.icon ?? ''
markerNote = ''
markerError = null
placing = false
markerDialog = true
}
/**
* Typwechsel übernimmt dessen Icon — als Vorschlag. Wer danach ein anderes
* wählt, behält es; erst der nächste Typwechsel schlägt wieder etwas vor.
*/
function selectFlag(id: string) {
markerFlag = id
markerIcon = id ? (flags.getById(id)?.icon ?? '') : ''
}
function removeMarker(m: { id: string; note?: string }) {
app.confirm.request({
title: 'Marker löschen?',
text: m.note
? `„${m.note}" wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.`
: 'Der Marker wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.',
yes: async () => {
try {
await markers.remove(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht gelöscht werden.')
} finally {
app.confirm.close()
}
},
no: () => app.confirm.close(),
})
}
/**
* Marker aus dem Höhenprofil heraus. Die Stelle kommt als Streckenkilometer
* und wird auf dieselbe Linie zurückgerechnet, die auch ein Klick auf der
* Karte trifft — beide Wege landen deshalb exakt auf dem Trail.
*/
function placeFromProfile(distance: number) {
const coords = version?.geojson?.coordinates
if (!coords?.length) return
const at = positionAtDistance(coords, version?.coord_distances ?? [], distance)
if (!at) return
onPlace({ lng: at[0], lat: at[1], distance })
}
async function saveMarker() {
if (!trail || !markerCoords) return
if (!markerFlag) {
markerError = 'Bitte einen Flag-Typ wählen.'
if (!markerFlag && !markerLabel.trim()) {
markerError = 'Bitte einen Typ wählen oder eine eigene Bezeichnung eintragen.'
return
}
@ -139,12 +239,17 @@
await markers.create({
trail: trail.id,
flag: markerFlag,
// Bei einem vorgefertigten Typ steht die Bezeichnung schon dort;
// sie hier zu kopieren hieße, sie zweimal zu pflegen.
label: markerFlag ? '' : markerLabel.trim(),
icon: markerIcon.trim(),
lat: markerCoords.lat,
lng: markerCoords.lng,
note: markerNote,
})
markerDialog = false
markerCoords = null
markerDistance = null
} catch (e: any) {
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
}
@ -152,7 +257,9 @@
// --- Kommentare ------------------------------------------------------
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
let comments = $state<TrailCommentsResponse[]>([])
type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }>
let comments = $state<Comment[]>([])
let commentText = $state('')
let commentError = $state<string | null>(null)
let unsubComments: (() => void) | null = null
@ -164,20 +271,30 @@
let cancelled = false
api.collection('trail_comments')
.getFullList({ filter: `trail="${id}"`, sort: '-created', requestKey: null })
.getFullList({
filter: `trail="${id}"`,
sort: '-created',
expand: 'created_by',
requestKey: null,
})
.then((r) => {
if (!cancelled) comments = r
if (!cancelled) comments = r as Comment[]
})
.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)
})
.subscribe(
'*',
(e) => {
if (e.record.trail !== id) return
const record = e.record as Comment
const idx = comments.findIndex((c) => c.id === record.id)
if (e.action === 'create' && idx === -1) comments = [record, ...comments]
else if (e.action === 'update' && idx !== -1) comments[idx] = record
else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== record.id)
},
{ expand: 'created_by' },
)
.then((u) => {
if (cancelled) u()
else unsubComments = u
@ -209,25 +326,18 @@
}
// --- 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)
if (!trail || status === trail.status) return
try {
await trails.setStatus(trail.id, status)
} catch (e: any) {
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
}
}
</script>
@ -242,251 +352,306 @@
{:else}
<div class="space-y-6">
<!-- Kopf -->
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Trails
</Button>
<h1 class="text-2xl font-semibold">{trail.name}</h1>
{#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if}
</div>
<div class="space-y-2">
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Trails
</Button>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
<div class="flex items-center gap-2">
<Badge variant={STATUS_VARIANT[trail.status] ?? 'default'}>
{STATUS_LABEL[trail.status] ?? trail.status}
</Badge>
{#if canEdit}
<Button variant="outline" size="sm" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
</div>
{#if canEdit}
<div class="flex gap-2">
{#each ['offen', 'eingeschraenkt', 'gesperrt'] as s (s)}
<Button
variant={trail.status === s ? 'default' : 'outline'}
size="sm"
onclick={() => setStatus(s)}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
<Button
{...props}
variant="outline"
size="sm"
class="gap-2"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
<ChevronDown class="size-3 opacity-60" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-60">
<DropdownMenu.Label>Zustand melden</DropdownMenu.Label>
{#each TRAIL_STATUS as s (s.value)}
<DropdownMenu.Item
onclick={() => setStatus(s.value)}
class={s.value === trail.status ? 'bg-accent' : ''}
>
<span class="size-2 rounded-full mr-2 shrink-0" style:background={s.color}></span>
<span class="flex-1">{s.label}</span>
<span class="text-xs text-muted-foreground">{s.hint}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<span
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
{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}
coordDistances={version.coord_distances ?? []}
highlightDistance={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>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
</span>
{/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>
{#if version}
<span class="flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5">
<Ruler class="size-4" />
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
</span>
<span class="flex items-center gap-1.5">
<TrendingUp class="size-4" />
<strong class="text-foreground">{version.ascent_m}</strong> hm
</span>
</span>
{/if}
</div>
{#if trail.status_changed}
<p class="text-xs text-muted-foreground">
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
gemeldet am {formatDate(trail.status_changed)}
{#if trail.status_by}
von {teams.userLabel(trail.status_by)}
{/if}
</p>
{/if}
{#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if}
</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>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<Button
variant="ghost"
size="sm"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
await markers.toggleResolved(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
}
}}
>
<Check class="size-4" />
</Button>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
{:else if tab === 'kommentare'}
<div class="grid gap-6 lg:grid-cols-3 items-start">
<!-- Karte und Höhenprofil -->
<Card class="lg:col-span-2">
<CardContent class="pt-6 space-y-4">
{#if version?.geojson}
<TrailMap
geojson={version.geojson}
bounds={version.bounds}
markers={mapMarkers}
status={trail.status}
coordDistances={version.coord_distances ?? []}
highlightDistance={highlight}
onplace={canPlace ? onPlace : undefined}
ontrailhover={(d) => (highlight = d)}
/>
{#if version.elevation?.length}
<ElevationProfile
elevation={version.elevation}
highlightDistance={highlight}
onhover={(d) => (highlight = d)}
onselect={canPlace ? placeFromProfile : undefined}
/>
{/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>
<!-- Marker, Kommentare, Versionen -->
<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}
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MapPin class="size-4 text-primary" />
Marker ({trailMarkers.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<p class="text-xs text-muted-foreground">
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
Marker zu setzen.
</p>
{#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 trailMarkers.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
{:else}
<ul class="divide-y -mx-2">
{#each trailMarkers as m (m.id)}
{@const look = markerLook(m)}
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
<FlagIcon name={look.icon} color={look.color} class="size-5" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{look.label}</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)} · {markers.authorName(m)}
</p>
</div>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<div class="flex shrink-0">
<Button
variant="ghost"
size="icon"
class="size-8"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
await markers.toggleResolved(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
}
}}
>
<Check class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
title="Marker löschen"
onclick={() => removeMarker(m)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
</div>
{/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>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
{#if v.gpx}
<Button variant="ghost" size="sm" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
<Download class="size-4" />
</Button>
{/if}
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MessageSquare class="size-4 text-primary" />
Kommentare ({comments.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<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 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}
{#if comments.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p>
{:else}
<ul class="divide-y -mx-2">
{#each comments as c (c.id)}
{@const author = c.expand?.created_by}
<li class="flex gap-3 px-2 py-3">
<!-- Nur das Bild; wer dahintersteckt, sagt der Tooltip. -->
<span
use:tooltip={{
content: author?.name || author?.email || 'Unbekannt',
}}
>
<Avatar user={author} size={28} />
</span>
<div class="min-w-0 flex-1">
<p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</div>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
<Card>
<!-- Versionen sind Archiv: zugeklappt, bis jemand sie braucht. -->
<button
type="button"
class="w-full flex items-center gap-2 px-6 py-4 text-left"
aria-expanded={showVersions}
onclick={() => (showVersions = !showVersions)}
>
<History class="size-4 text-primary" />
<span class="font-semibold text-base flex-1">
Versionen ({trailVersions.length})
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform"
style={showVersions ? 'transform: rotate(180deg)' : ''}
/>
</button>
{#if showVersions}
<CardContent class="pt-0 space-y-3">
{#if canEdit}
<Button variant="outline" size="sm" class="w-full" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
{#if trailVersions.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Version hochgeladen.</p>
{:else}
<ul class="divide-y -mx-2">
{#each trailVersions as v (v.id)}
<li class="px-2 py-3 flex items-start gap-2">
<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-xs 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="icon" class="size-8" 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}
</li>
{/each}
</ul>
{/if}
</CardContent>
{/if}
</Card>
</div>
</div>
</div>
{/if}
@ -534,24 +699,47 @@
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Marker setzen</Dialog.Title>
{#if markerDistance != null}
<Dialog.Description>
bei km {(markerDistance / 1000).toFixed(2)} des Trails
</Dialog.Description>
{/if}
</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>
<Select.Root type="single" value={markerFlag} onValueChange={selectFlag}>
<Select.Trigger class="w-full">
{#if selectedFlag}
<span class="flex items-center gap-2">
<span class="size-2 rounded-full" style:background={selectedFlag.color}></span>
{selectedFlag.label}
</span>
{:else}
Eigener Typ
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value={CUSTOM}>Eigener Typ …</Select.Item>
{#each flags.scoped as f (f.id)}
<Select.Item value={f.id}>
<span class="size-2 rounded-full mr-2 shrink-0" style:background={f.color}></span>
{f.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if !markerFlag}
<div class="space-y-2">
<Label for="marker-label">Bezeichnung</Label>
<Input id="marker-label" bind:value={markerLabel} placeholder="z. B. Wespennest am Anlieger" />
</div>
{/if}
<IconPicker id="marker-icon" bind:value={markerIcon} color={markerColor} />
<div class="space-y-2">
<Label for="marker-note">Notiz</Label>
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />