Compare commits

..

7 commits

Author SHA1 Message Date
Daniel Michelberger
e7892be60b docs: TODO an den zusammengelegten Fahrer-Reiter angepasst
Der Pfad hiess noch "Einstellungen -> Fahrer -> Konto"; der Reiter heisst
seit e3addda "Fahrer & Konten". Dazu zwei Punkte, die beim Zusammenlegen
entstanden sind: dass die Regel "jedes Teammitglied ist ein Fahrer" nur in
der Oberflaeche gilt, und dass "Als Fahrer uebernehmen" den Namen raet.

Migrationsstand erneut gegen die produktive Instanz geprueft - unveraendert
fuenf offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 18:22:49 +02:00
Daniel Michelberger
e3adddafda refactor: Fahrer und Konten in einem Reiter, Team wird zur Uebersicht
Dieselben Menschen wurden an zwei Stellen gepflegt: Mitglieder unter Team,
Fahrer unter Fahrer - und die Zuordnung dazwischen von Hand. Jedes
Teammitglied ist aber ein Fahrer; nur hat nicht jeder Fahrer ein Konto.

"Fahrer & Konten" fuehrt beides in einer Liste: Kader, daneben wer sich
anmelden kann und mit welcher Rolle. Angelegt wird eine Person als Fahrer,
das Konto ist eine Checkbox im selben Dialog - mit E-Mail, sobald sie
gesetzt ist. Rollen, Entfernen aus dem Team und die Kontosuche wandern
ebenfalls hierher.

Konten im Team ohne Fahrer bekommen eine eigene Zeile samt "Als Fahrer
uebernehmen". Sie verschwaenden sonst aus der Ansicht, obwohl sie Mitglied
sind.

"Team" bleibt als Uebersicht: Kennzahlen des aktiven Teams, Name, Logo,
Wechseln, Verlassen, Loeschen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 16:18:47 +02:00
Daniel Michelberger
b1594d99c4 docs: TODO gegen die produktive Instanz abgeglichen
Sechs der elf Migrationen sind laengst gelaufen - der Backend-Deploy hat
alles bis 1754500900 mitgenommen. Die Datei behauptete weiter, es stehe
alles aus, samt eines Symptoms ("Only superusers can perform this action"),
das es so nicht mehr gibt.

Die Tabelle sagt jetzt je Migration, was gelaufen ist und was nicht, und
benennt, welche Funktionen bis zum naechsten Deploy ins Leere laufen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 16:14:35 +02:00
Daniel Michelberger
fae50d3209 feat: Fahrer werden zum Kader, Events bekommen Teilnehmer und Serien
Damit sind alle offenen Punkte aus TODO.md gebaut.

Fahrer gehoeren ab jetzt dem Team, nicht mehr einem Event. Wer bei einem
Termin dabei ist, steht in event_participants - dort haengt auch die
Startnummer, denn die gilt je Event: Derselbe Fahrer kann in zwei Rennen
zwei Nummern haben und beim Stammtisch gar keine. Die vorhandenen Daten
wandern in der Migration mit.

Events tragen zwei Schalter. timing blendet Runs und Zeitnahme ein - der
woechentliche Stammtisch braucht sie nicht. participation regelt, wie man
dazukommt: offen (jeder traegt sich selbst ein), bestaetigung (Anmeldung,
Ersteller oder Team-Admin bestaetigt) oder geschlossen. Durchgesetzt wird
das serverseitig, nicht nur im Formular.

Serien erzeugen echte Einzeltermine statt einer Wiederholungsregel, aus der
Termine nur gerechnet werden. Nur so laesst sich beantworten, wer am 12.
kommt, und nur so faellt eine einzelne Woche aus. Gerechnet wird auf
Kalendertagen: ueber die Zeitumstellung hinweg kippte ein Termin sonst auf
den Vortag - dafuer gibt es einen Test.

Ausserdem: Teammitglieder duerfen den Zustand eines Trails melden. Die Regel
gibt genau die drei Statusfelder frei und schliesst jedes andere aus; ohne
das haette man Mitgliedern das ganze Objekt oeffnen muessen. Und bestehende
Konten lassen sich wieder per E-Mail ins Team holen, wofuer users.listRule
sich oeffnet.

Die neuen Regeln sind ungetestet - weder Docker noch eine lokale
PocketBase-Instanz standen zur Verfuegung. TODO.md sagt, was nach dem Deploy
durchzuspielen ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 16:03:17 +02:00
Daniel Michelberger
45a4191f01 refactor: Tooltips ueber tippy statt title-Attribute, appweit
title war fuer Hinweise die falsche Wahl: Es erscheint erst nach einer
Sekunde, sieht auf jedem System anders aus und laesst sich auf Touch-Geraeten
gar nicht aufrufen. Tippy war laengst als Abhaengigkeit da, aber kaum
benutzt.

<Button> nimmt jetzt eine tooltip-Prop, die zugleich als aria-label dient -
ohne das haetten die vielen Icon-Knoepfe beim Wegfall von title ihren Namen
fuer Screenreader verloren. Alle anderen Elemente nutzen die Action direkt.

Die Action selbst konnte bisher nur erzeugen. Sie beherrscht jetzt
Aktualisieren und Aufraeumen: Ein wechselnder Text ("Erledigt" / "Wieder
oeffnen") muss auch im Tooltip wechseln, und eine Instanz, die ihr Element
ueberlebt, haenge als leere Blase im Dokument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 15:45:49 +02:00
Daniel Michelberger
4fa0de8753 feat: Logo pro Team, auch als Wasserzeichen im Seitenhintergrund
teams.logo nimmt eine Datei auf; hochgeladen wird sie in den
Team-Einstellungen. Ohne eigenes Logo bleibt das Wappen aus static/ als
Rueckfalloption - fuer den Stammtisch Hersbruck aendert sich damit nichts,
fuer jedes weitere Team waere es fremdes Vereinswappen gewesen.

Dasselbe Logo liegt gedreht und sehr blass in der rechten unteren Ecke der
Seite. Es haengt fest im Bildschirm, faengt keine Klicks ab und ist fuer
Screenreader nicht vorhanden; im Dunkelmodus etwas kraeftiger, sonst
verschwaende es im dunklen Grund.

In TODO.md ausserdem der Plan fuer Ausfahrten: Sie werden kein zweites
Konzept neben Events, sondern Schalter daran - timing, participation
(offen/bestaetigung/geschlossen) und Serien, die echte Einzeltermine
erzeugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 15:42:04 +02:00
Daniel Michelberger
fb2652dcbf feat: Trail-Karten zeigen Marker und Kommentare, Icons fuer Laenge und Hoehe
Laenge und Hoehenmeter tragen dieselben Icons wie der Kopf der Trail-Seite.
Dazu kommen Anzahl der Marker und Kommentare - und ein Punkt daneben, wenn
davon etwas aus den letzten 14 Tagen stammt. Die blosse Zahl sagt nichts
darueber, ob sich gerade etwas tut.

Kommentare bekommen dafuer einen eigenen Store. Sie lagen bisher direkt in
der Trail-Seite, was richtig war, solange es sie nur dort gab; fuer die
Liste waere ein zweiter Ladeweg auf dieselben Daten einer zu viel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 15:36:22 +02:00
36 changed files with 4635 additions and 2403 deletions

View file

@ -115,6 +115,12 @@ The app uses Svelte 5 runes for state management with a custom store pattern:
**Hotkeys**: Use `app.hotkey(event, condition, callback)` which auto-ignores input fields and contenteditable elements.
**Tooltips**: Never use the `title` attribute for tooltips — appwide. `<Button>`
takes a `tooltip="…"` prop (it also becomes the `aria-label`, so icon-only
buttons keep an accessible name); every other element uses the action:
`use:tooltip={{ content: '…' }}` from `app.svelte.ts`. Both render through
tippy.js, which the action initializes, updates and destroys.
### Project Configuration
- **Repo-Struktur**: Monorepo mit `frontend/` (SvelteKit) und `backend/`

150
TODO.md
View file

@ -1,40 +1,8 @@
# 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.
Stand: 2026-09-02, abgeglichen mit der produktiven Instanz. Die fachlichen
Entscheidungen sind gefallen und gebaut; was hier steht, passiert außerhalb des
Repos oder wartet auf den nächsten Deploy.
## Vor dem nächsten Deploy
@ -48,35 +16,49 @@ Gelesen wird er über `$env/dynamic/public`, das heißt: Die Variable muss in de
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
### Migrationen: sechs gelaufen, fünf offen
Fünf neue Migrationen liegen in `backend/pb_migrations/`:
Abgeglichen mit `api.stammtisch-hersbruck.de` (nur gelesen): Der Backend-Deploy
hat alles bis `1754500900` mitgenommen, ab `1754501000` steht es noch aus.
- `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`
| Datei | Was sie tut | Stand |
|---|---|---|
| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur Teamleitung, `users.viewRule`/`createRule` | gelaufen |
| `1754500600_events_dates_instead_of_status` | `events.status` raus, `starts`/`ends` rein | gelaufen |
| `1754500700_trail_status_meta` | `trails.status_changed`, `status_by` | gelaufen |
| `1754500800_trail_markers_custom` | `trail_markers.label`/`icon`, `flag` optional | gelaufen |
| `1754500900_users_superadmin` | `users.superadmin` samt Schutz vor Selbstbeförderung | gelaufen |
| `1754501000_teams_logo` | `teams.logo` | **offen** |
| `1754501100_trails_status_report` | Mitglieder dürfen den Trail-Zustand melden | **offen** |
| `1754501200_users_list_for_invites` | `users.listRule` gelockert (Kontosuche) | **offen** |
| `1754501300_event_participants` | neue Collection, **verschiebt Daten**, entfernt `riders.event`/`number` | **offen** |
| `1754501400_events_timing_participation` | `events.timing`/`participation` | **offen** |
| `1754501500_event_series` | `event_series`, `events.series` | **offen** |
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).
Bis zum nächsten Deploy laufen deshalb ins Leere: Team-Logo, Zustandsmeldung
durch Mitglieder, Kontosuche per E-Mail, Teilnehmerlisten, die Event-Schalter
und die Serien. Die Oberfläche dafür ist da und wird beim Speichern eine
Fehlermeldung von PocketBase zeigen.
**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.
**Zwei Dinge sind beim Nachziehen 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.
1. `1754501300` legt für jeden Fahrer mit Event einen Teilnehmer an und entfernt
danach `riders.event` und `riders.number`. Eine Startnummer an einem Fahrer
**ohne** Event hat danach keinen Platz mehr und geht verloren — sie war
ohnehin nirgends sichtbar, weil alle Listen nach Event filterten.
2. Bestehende Events werden auf `timing = true` und `participation =
geschlossen` gesetzt. Genau so wurden sie bisher benutzt; wer einen
Stammtisch daraus machen will, schaltet die Zeitnahme im Dialog ab.
Ebenfalls bewusst so: Nach `1754501200` kann jeder Eingeloggte Konten
auflisten, damit sich bestehende Konten per E-Mail ins Team holen lassen.
E-Mail-Adressen bleiben über `emailVisibility` verborgen.
### 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.
`frontend/src/lib/types.d.ts` wurde bei allen Schema-Änderungen von Hand
gepflegt. Grund: `pocketbase-typegen` braucht native sqlite3-Bindings, die in
der Arbeitsumgebung nicht gebaut werden durften.
```bash
cd frontend && npm run generate-pocketbase-types
@ -85,16 +67,52 @@ cd frontend && npm run generate-pocketbase-types
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
des Generators.
### Regeln einmal durchspielen
Die Zugriffsregeln der neuen Collections konnten nirgends laufen — weder Docker
noch eine lokale PocketBase-Instanz standen zur Verfügung. Sie sind nach dem
Muster der vorhandenen Collections geschrieben, aber ungetestet. Nach dem Deploy
einmal ausprobieren:
- Ein einfaches Teammitglied trägt sich bei einem **offenen** Event selbst ein.
- Dasselbe bei einem Event **mit Bestätigung** — es muss als „Angefragt"
landen, nicht als „Dabei".
- Bei einem **geschlossenen** Event darf es gar nicht gehen.
- Ein Mitglied meldet einen Trail-Zustand (muss gehen) und versucht, den Namen
des Trails zu ändern (darf nicht gehen).
Der Selbsteintrag setzt außerdem voraus, dass das Konto der Person mit einem
Fahrer verknüpft ist (Einstellungen → Fahrer & Konten). Ohne Verknüpfung
erscheint der Knopf gar nicht erst.
### Logo des Stammtischs zuordnen
`teams.logo` gibt es ab `1754501000`. Solange nichts hinterlegt ist, zeigt die
App weiterhin das Wappen aus `static/` — für den Stammtisch Hersbruck sieht es
also unverändert aus. Wer es als Datei am Team haben will, lädt es nach dem
Deploy unter Einstellungen → Team → Logo hoch.
## 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.
- Wer Regeln direkt im PocketBase-Admin ändert, hinterlässt keine Spur im Repo.
Die Migrationen bleiben die Quelle: Sie setzen dieselben Werte noch einmal und
überschreiben eine abweichende Handänderung stillschweigend.
- Die Rolle **Superadmin** ist 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 sind **teamspezifisch**. Ein neues Team bekommt beim Anlegen den
hartkodierten Standardsatz aus `trailFlags.svelte.ts` und kann ihn frei
ändern.
- „Jedes Teammitglied ist ein Fahrer" hält die Oberfläche ein, das Backend
erzwingt es nicht: Ein Konto kann im Team sein, ohne an einem Fahrer zu
hängen. Solche Fälle stehen unter „Fahrer & Konten" mit dem Vermerk „kein
Fahrer" und lassen sich per Knopf übernehmen.
- „Als Fahrer übernehmen" rät den Namen aus dem Kontonamen und setzt ihn als
Vornamen. „Max Mustermann" auf zwei Felder aufzuteilen wäre geraten und läge
bei Doppelnamen daneben; der Name lässt sich danach korrigieren. Wenn das zu
grob ist, müsste der Knopf stattdessen nach Vor- und Nachname fragen.
- Serien erzeugen bis zu zwölf Termine je Klick und überspringen, was schon
angelegt ist. Ein Termin, der ausfällt, wird als Event gelöscht oder
verschoben — die Serie bleibt davon unberührt.
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
// Jedes Team bringt sein eigenes Logo mit.
//
// Bisher steckte das Wappen fest in der Anwendung (static/schildl.svg und
// static/radl.svg). Für den Stammtisch Hersbruck stimmt das, für jedes
// weitere Team wäre es fremdes Vereinswappen. Ohne hinterlegte Datei bleibt
// die bisherige Darstellung als Rückfalloption bestehen.
//
// 5 MB, weil die vorhandenen Wappen als Inkscape-SVG je rund 2,5 MB wiegen.
migrate((app) => {
const teams = app.findCollectionByNameOrId('teams')
if (!teams.fields.getByName('logo')) {
teams.fields.add(new Field({
id: 'file_team_logo',
name: 'logo',
type: 'file',
maxSelect: 1,
maxSize: 5242880,
mimeTypes: [
'image/jpeg',
'image/png',
'image/svg+xml',
'image/gif',
'image/webp',
],
}))
}
app.save(teams)
}, (app) => {
const teams = app.findCollectionByNameOrId('teams')
teams.fields.removeByName('logo')
app.save(teams)
})

View file

@ -0,0 +1,39 @@
/// <reference path="../pb_data/types.d.ts" />
// Jedes Teammitglied darf den Zustand eines Trails melden.
//
// Bisher durften das nur Paten und Team-Admins. Genau die sind aber selten
// die, die vor dem umgestürzten Baum stehen. Gemeldet werden darf deshalb von
// allen — geändert werden darf dabei aber ausschließlich der Zustand.
//
// PocketBase kennt keine Rechte je Feld, also wird jedes andere Feld
// ausdrücklich ausgeschlossen. Das ist lang und muss mitwachsen, wenn ein
// Feld dazukommt; die Alternative wäre, Mitgliedern das ganze Objekt
// freizugeben. `status_by = @request.auth.id` verhindert außerdem, eine
// Meldung unter fremdem Namen abzusetzen.
const STEWARD_OR_ADMIN =
'stewards.id ?= @request.auth.id' +
' || team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id'
const MEMBER_REPORTS_STATUS =
'team.users.id ?= @request.auth.id' +
' && @request.body.team:isset = false' +
' && @request.body.name:isset = false' +
' && @request.body.description:isset = false' +
' && @request.body.stewards:isset = false' +
' && @request.body.event:isset = false' +
' && @request.body.current:isset = false' +
' && @request.body.created_by:isset = false' +
' && @request.body.status_by = @request.auth.id'
migrate((app) => {
const trails = app.findCollectionByNameOrId('trails')
trails.updateRule = `${STEWARD_OR_ADMIN} || (${MEMBER_REPORTS_STATUS})`
app.save(trails)
}, (app) => {
const trails = app.findCollectionByNameOrId('trails')
trails.updateRule = STEWARD_OR_ADMIN
app.save(trails)
})

View file

@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
// Konten lassen sich wieder über ihre E-Mail-Adresse finden.
//
// Die Einladung eines bestehenden Kontos in ein Team braucht genau das: eine
// Suche. Mit `id = @request.auth.id` fand die Suche immer nur einen selbst,
// weshalb die Funktion faktisch nie funktioniert hat.
//
// Damit kann jeder Eingeloggte die Konten der Instanz auflisten. Für einen
// Verein mit eingeladenen Mitgliedern ist das vertretbar; E-Mail-Adressen
// bleiben über emailVisibility weiterhin verborgen, und anlegen kann Konten
// ohnehin nur, wer eingeloggt ist.
migrate((app) => {
const users = app.findCollectionByNameOrId('users')
users.listRule = '@request.auth.id != ""'
app.save(users)
}, (app) => {
const users = app.findCollectionByNameOrId('users')
users.listRule = 'id = @request.auth.id'
app.save(users)
})

View file

@ -0,0 +1,146 @@
/// <reference path="../pb_data/types.d.ts" />
// Teilnehmer eines Events — und damit die Auflösung der Frage, ob Fahrer zum
// Team oder zum Event gehören: zum Team.
//
// `riders` ist ab hier der Kader des Teams. Wer bei einem Event dabei ist,
// steht in `event_participants`; dort hängt auch die Startnummer, denn die
// gilt je Event und nicht je Person. Ein Fahrer kann so über Jahre an vielen
// Events teilnehmen, ohne dass er mehrfach angelegt werden muss.
//
// Die vorhandenen Daten wandern mit: Jeder Fahrer mit Event wird zu einem
// Teilnehmer samt seiner bisherigen Nummer. Danach entfallen `riders.event`
// und `riders.number`.
//
// ACHTUNG: Eine Nummer an einem Fahrer OHNE Event hat danach keinen Platz
// mehr und geht verloren. Sie war ohnehin nirgends sichtbar, weil alle
// Listen nach Event gefiltert haben.
migrate((app) => {
const events = app.findCollectionByNameOrId('events')
const riders = app.findCollectionByNameOrId('riders')
const teams = app.findCollectionByNameOrId('teams')
// Wer das Event angelegt hat, darf seine Teilnehmer verwalten — ebenso
// die Teamleitung. Das Feld kommt in derselben Migration dazu, damit die
// Regeln unten darauf zeigen können.
if (!events.fields.getByName('created_by')) {
events.fields.add(new Field({
id: 'relation_event_created_by',
name: 'created_by',
type: 'relation',
collectionId: app.findCollectionByNameOrId('users').id,
cascadeDelete: false,
maxSelect: 1,
minSelect: 0,
required: false,
}))
app.save(events)
}
let participants
try {
participants = app.findCollectionByNameOrId('event_participants')
} catch {
// Verwaltet wird die Teilnehmerliste vom Ersteller des Events und von
// der Teamleitung. Wer sich selbst einträgt, hängt am
// Teilnahmemodus — das regelt 1754501400, sobald es das Feld gibt.
const manager =
'event.created_by.id ?= @request.auth.id' +
' || event.team.owner.id ?= @request.auth.id' +
' || event.team.admins.id ?= @request.auth.id'
participants = new Collection({
type: 'base',
name: 'event_participants',
listRule: 'event.team.users.id ?= @request.auth.id',
viewRule: 'event.team.users.id ?= @request.auth.id',
createRule: manager,
updateRule: manager,
deleteRule: manager,
fields: [
{
name: 'event',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: events.id,
cascadeDelete: true,
},
{
name: 'rider',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: riders.id,
cascadeDelete: true,
},
{ name: 'number', type: 'text', max: 20 },
{
name: 'status',
type: 'select',
maxSelect: 1,
values: ['angefragt', 'zugesagt', 'abgesagt'],
},
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: app.findCollectionByNameOrId('users').id,
cascadeDelete: false,
},
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
})
app.save(participants)
}
// --- Bestand übernehmen ---------------------------------------------
const existing = app.findRecordsByFilter('riders', 'event != ""', '', 0, 0)
for (const rider of existing) {
const record = new Record(participants)
record.set('event', rider.get('event'))
record.set('rider', rider.id)
record.set('number', rider.get('number'))
record.set('status', 'zugesagt')
app.save(record)
}
riders.fields.removeByName('event')
riders.fields.removeByName('number')
app.save(riders)
}, (app) => {
const riders = app.findCollectionByNameOrId('riders')
const events = app.findCollectionByNameOrId('events')
riders.fields.add(new Field({
name: 'event',
type: 'relation',
collectionId: events.id,
cascadeDelete: false,
maxSelect: 1,
}))
riders.fields.add(new Field({ name: 'number', type: 'text' }))
app.save(riders)
// Zurück wandert nur das Offensichtliche: das erste Event je Fahrer.
const participants = app.findRecordsByFilter('event_participants', 'id != ""', '', 0, 0)
for (const p of participants) {
try {
const rider = app.findRecordById('riders', p.get('rider'))
if (!rider.get('event')) {
rider.set('event', p.get('event'))
rider.set('number', p.get('number'))
app.save(rider)
}
} catch {
// Fahrer inzwischen gelöscht — nichts zu tun.
}
}
app.delete(app.findCollectionByNameOrId('event_participants'))
events.fields.removeByName('created_by')
app.save(events)
})

View file

@ -0,0 +1,93 @@
/// <reference path="../pb_data/types.d.ts" />
// Events werden zu Ausfahrten, Rennen, Stammtischen — je nach Schaltern.
//
// `timing` blendet Runs und Zeitnahme ein. Der wöchentliche Stammtisch
// braucht das nicht, das Rennen schon; ein zweites Konzept neben Events wäre
// dafür zu viel gewesen.
//
// `participation` legt fest, wie man teilnimmt:
// offen — jedes Teammitglied trägt sich selbst ein
// bestaetigung — Anmeldung möglich, Ersteller oder Team-Admin bestätigt
// geschlossen — nur Ersteller oder Team-Admin trägt ein
//
// Bestehende Events bekommen `timing = true` und `geschlossen`: Genau so
// wurden sie bisher benutzt.
const MANAGER =
'event.created_by.id ?= @request.auth.id' +
' || event.team.owner.id ?= @request.auth.id' +
' || event.team.admins.id ?= @request.auth.id'
// Sich selbst eintragen: nur den eigenen Datensatz, nur mit dem Status, den
// der Modus vorsieht. Ohne die Statusprüfung könnte man sich im
// Bestätigungsmodus gleich selbst bestätigen.
//
// Geprüft wird auf den Feldern des entstehenden Datensatzes statt über
// @request.body: Eine Relation aus dem Request-Body heraus weiterzuverfolgen
// (@request.body.rider.user) ist nicht verlässlich.
const SELF_SIGNUP =
'event.team.users.id ?= @request.auth.id' +
' && rider.user.id ?= @request.auth.id' +
' && (' +
'(event.participation = "offen" && status = "zugesagt")' +
' || (event.participation = "bestaetigung" && status = "angefragt")' +
')'
// Die eigene Teilnahme darf man ändern — aber nicht in eine Bestätigung.
const SELF_UPDATE =
'rider.user.id ?= @request.auth.id' +
' && @request.body.rider:isset = false' +
' && @request.body.event:isset = false' +
' && @request.body.number:isset = false' +
' && @request.body.status != "zugesagt"'
migrate((app) => {
const events = app.findCollectionByNameOrId('events')
if (!events.fields.getByName('timing')) {
events.fields.add(new Field({
id: 'bool_event_timing',
name: 'timing',
type: 'bool',
}))
}
if (!events.fields.getByName('participation')) {
events.fields.add(new Field({
id: 'select_event_participation',
name: 'participation',
type: 'select',
maxSelect: 1,
values: ['offen', 'bestaetigung', 'geschlossen'],
}))
}
app.save(events)
for (const event of app.findRecordsByFilter('events', 'id != ""', '', 0, 0)) {
event.set('timing', true)
event.set('participation', 'geschlossen')
app.save(event)
}
const participants = app.findCollectionByNameOrId('event_participants')
participants.createRule = `${MANAGER} || (${SELF_SIGNUP})`
participants.updateRule = `${MANAGER} || (${SELF_UPDATE})`
// Absagen heißt Status ändern, nicht löschen — sonst verschwindet die
// Historie einer Anmeldung.
participants.deleteRule = MANAGER
app.save(participants)
}, (app) => {
const events = app.findCollectionByNameOrId('events')
events.fields.removeByName('timing')
events.fields.removeByName('participation')
app.save(events)
const participants = app.findCollectionByNameOrId('event_participants')
participants.createRule = MANAGER
participants.updateRule = MANAGER
participants.deleteRule = MANAGER
app.save(participants)
})

View file

@ -0,0 +1,100 @@
/// <reference path="../pb_data/types.d.ts" />
// Wiederkehrende Termine — vom wöchentlichen Stammtisch bis zur monatlichen
// Feierabendrunde.
//
// Eine Serie beschreibt die Regel und erzeugt daraus echte Einzeltermine;
// sie ist keine Wiederholungsregel, aus der Termine nur gerechnet werden.
// Der Unterschied zählt, sobald jemand fragt „wer kommt am 12.?" oder
// „diese Woche fällt aus": Beides braucht einen Datensatz je Termin, den man
// absagen, verschieben und bespielen kann.
//
// Die Serie trägt dafür die Vorlage (Ort, Beschreibung, Schalter), aus der
// neue Termine entstehen. Ändert sich die Vorlage, gilt das für künftige
// Termine — bereits erzeugte bleiben, wie sie sind.
migrate((app) => {
const teams = app.findCollectionByNameOrId('teams')
const users = app.findCollectionByNameOrId('users')
const events = app.findCollectionByNameOrId('events')
let series
try {
series = app.findCollectionByNameOrId('event_series')
} catch {
const admin =
'team.owner.id ?= @request.auth.id' +
' || team.admins.id ?= @request.auth.id' +
' || created_by.id ?= @request.auth.id'
series = new Collection({
type: 'base',
name: 'event_series',
listRule: 'team.users.id ?= @request.auth.id',
viewRule: 'team.users.id ?= @request.auth.id',
createRule: 'team.users.id ?= @request.auth.id',
updateRule: admin,
deleteRule: admin,
fields: [
{
name: 'team',
type: 'relation',
required: true,
maxSelect: 1,
collectionId: teams.id,
cascadeDelete: true,
},
{ name: 'name', type: 'text', required: true, max: 200 },
{ name: 'location', type: 'text', max: 200 },
{ name: 'description', type: 'text', max: 2000 },
{
name: 'rule',
type: 'select',
maxSelect: 1,
values: ['woechentlich', 'zweiwoechentlich', 'monatlich'],
},
{ name: 'starts', type: 'date' },
{ name: 'until', type: 'date' },
{ name: 'timing', type: 'bool' },
{
name: 'participation',
type: 'select',
maxSelect: 1,
values: ['offen', 'bestaetigung', 'geschlossen'],
},
{
name: 'created_by',
type: 'relation',
maxSelect: 1,
collectionId: users.id,
cascadeDelete: false,
},
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
})
app.save(series)
}
// cascadeDelete bleibt aus: Wer eine Serie beendet, will selten alle
// gelaufenen Termine samt Zeiten und Teilnehmern mitlöschen.
if (!events.fields.getByName('series')) {
events.fields.add(new Field({
id: 'relation_event_series',
name: 'series',
type: 'relation',
collectionId: series.id,
cascadeDelete: false,
maxSelect: 1,
minSelect: 0,
required: false,
}))
app.save(events)
}
}, (app) => {
const events = app.findCollectionByNameOrId('events')
events.fields.removeByName('series')
app.save(events)
app.delete(app.findCollectionByNameOrId('event_series'))
})

View file

@ -8,6 +8,7 @@
* beide dieselbe Auswahl zeigen.
*/
import FlagIcon from './FlagIcon.svelte'
import { tooltip } from '$lib/stores/app.svelte'
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@ -48,8 +49,8 @@
{#each FLAG_ICON_SUGGESTIONS as name (name)}
<button
type="button"
title={name}
aria-label={name}
use:tooltip={{ content: name }}
aria-pressed={value === name}
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
class:bg-accent={value === name}

View file

@ -1,10 +1,28 @@
<script lang="ts">
// Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
// (klein) und auf der Login-Karte (gross) verwendet werden kann.
let { class: className = 'w-24 h-24' }: { class?: string } = $props()
/**
* Logo eines Teams. Ist eins hinterlegt, steht es allein; sonst bleibt das
* Wappen des Stammtischs als Rückfalloption — zwei übereinanderliegende
* SVGs, weil das Radl über dem Schildl leicht übersteht.
*
* Groesse ueber eine Klasse steuerbar, damit dasselbe Logo im Header
* (klein) und auf der Login-Karte (gross) verwendet werden kann.
*/
import { getFileURL } from '$lib/stores/pocketbase.svelte'
import type { TeamsResponse } from '$lib/types'
let {
class: className = 'w-24 h-24',
team = null,
}: { class?: string; team?: TeamsResponse | null } = $props()
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
</script>
<div class="relative {className}" id="logo">
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
<img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
{#if url}
<img alt="" class="absolute inset-0 h-full w-full object-contain" src={url} />
{:else}
<img alt="" class="absolute inset-0 h-full w-full object-contain" src="/schildl.svg">
<img alt="" class="absolute inset-0 h-full w-full scale-110 object-contain" src="/radl.svg">
{/if}
</div>

View file

@ -14,6 +14,7 @@
import { getTimeContext } from '$lib/stores/times.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import TimeDialog from './TimeDialog.svelte'
import { Button } from '@/components/ui/button'
@ -22,6 +23,7 @@
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const participants = getEventParticipantContext()
const clock = ticker()
const running = $derived(times.allRunning(clock.now))
@ -49,10 +51,14 @@
// 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()
/** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
function riderLabel(riderId: string | undefined, runId: string | undefined) {
const eventId = runId ? runs.getById(runId)?.event : undefined
if (!eventId) {
const r = riderId ? riders.getById(riderId) : undefined
return r ? fullName(r) : 'Unbekannter Fahrer'
}
return participants.riderLabel(eventId, riderId)
}
function runPath(runId: string | undefined) {
@ -92,7 +98,7 @@
variant="ghost"
size="icon"
class="ml-auto size-7 shrink-0"
title={expanded ? 'Einklappen' : 'Alle anzeigen'}
tooltip={expanded ? 'Einklappen' : 'Alle anzeigen'}
onclick={() => (expanded = !expanded)}
>
{#if expanded}
@ -110,7 +116,7 @@
{@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-sm font-medium truncate">{riderLabel(t.rider, t.run)}</p>
<p class="text-xs text-muted-foreground truncate">
{#if path}
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
@ -128,7 +134,7 @@
variant="ghost"
size="icon"
class="shrink-0"
title="Zeit erfassen"
tooltip="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.run)}
>
<Timer class="size-4" />

View file

@ -9,6 +9,7 @@
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 { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@ -33,6 +34,7 @@
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const participants = getEventParticipantContext()
const clock = ticker()
let selectedRider = $state('')
@ -52,16 +54,19 @@
confirmDnf = false
})
const eventRiders = $derived(eventId ? riders.getByEvent(eventId) : [])
// Zur Auswahl steht, wer beim Event zugesagt hat — nicht der ganze Kader.
const eventRiders = $derived(
eventId
? participants
.confirmedByEvent(eventId)
.map((p) => riders.getById(p.rider))
.filter((r) => !!r)
: [],
)
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…',
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
)
const runLabel = $derived(
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
@ -135,7 +140,7 @@
<Select.Content>
{#each eventRiders as r (r.id)}
<Select.Item value={r.id}>
#{r.number || '—'} {fullName(r)}
{participants.riderLabel(eventId, r.id)}
</Select.Item>
{/each}
</Select.Content>

View file

@ -38,10 +38,18 @@
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
/**
* Hinweistext als Tooltip. Ersetzt `title` appweit und dient
* zugleich als Beschriftung, wenn der Knopf nur ein Icon zeigt —
* ohne das bliebe er für Screenreader namenlos.
*/
tooltip?: string;
};
</script>
<script lang="ts">
import { tooltip as tooltipAction } from "$lib/stores/app.svelte";
let {
class: className,
variant = "default",
@ -50,6 +58,7 @@
href = undefined,
type = "button",
disabled,
tooltip,
children,
...restProps
}: ButtonProps = $props();
@ -64,7 +73,9 @@
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
aria-label={tooltip}
{...restProps}
use:tooltipAction={{ content: tooltip }}
>
{@render children?.()}
</a>
@ -75,7 +86,9 @@
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
aria-label={tooltip}
{...restProps}
use:tooltipAction={{ content: tooltip }}
>
{@render children?.()}
</button>

View file

@ -160,20 +160,46 @@ export const utils = {
},
}
/**
* Tooltip für ein Element. Appweit die einzige Art, einen Hinweis an einen
* Knopf zu hängen `title` überlassen wir dem Browser nicht mehr: Es
* erscheint erst nach einer Sekunde, sieht auf jedem System anders aus und
* lässt sich auf Touch-Geräten gar nicht aufrufen.
*
* Die Action beherrscht Aktualisieren und Aufräumen: Ein Text, der sich
* ändert (»Erledigt« / »Wieder öffnen«), muss auch im Tooltip wechseln, und
* eine Instanz, die ein entferntes Element überlebt, hinge als leere Blase
* im Dokument.
*/
export const tooltip = (
element: HTMLElement,
props: { active?: boolean; content?: string; showOnCreate?: boolean },
props: { active?: boolean; content?: string; showOnCreate?: boolean } = {},
) => {
let defaultProps = {
active: true,
const options = (p: typeof props) => ({
animation: 'shift-toward-subtle',
allowHTML: true,
allowHTML: false,
content: p.content ?? '',
showOnCreate: p.showOnCreate,
})
const wanted = (p: typeof props) => p.active !== false && !!p.content?.trim()
let instance = wanted(props) ? tippy(element, options(props)) : null
return {
update(next: typeof props) {
if (!wanted(next)) {
instance?.destroy()
instance = null
return
}
if (instance) instance.setProps(options(next))
else instance = tippy(element, options(next))
},
destroy() {
instance?.destroy()
instance = null
},
}
props = {...defaultProps, ...props}
if (props.showOnCreate) {
props = {
...props,
}
}
if (props.active) tippy(element, props)
}

View file

@ -0,0 +1,184 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getEventContext } from './events.svelte'
import { getRiderContext } from './riders.svelte'
import type { EventParticipantsResponse, RidersResponse } from '$lib/types'
const KEY = Symbol('eventParticipant')
export type ParticipantStatus = 'angefragt' | 'zugesagt' | 'abgesagt'
export const PARTICIPANT_STATUS_LABEL: Record<string, string> = {
angefragt: 'Angefragt',
zugesagt: 'Dabei',
abgesagt: 'Abgesagt',
}
/**
* Teilnehmer eines Events. Fahrer gehören dem Team, nicht dem Event wer bei
* einem Termin dabei ist, steht hier. Die Startnummer hängt ebenfalls hier,
* denn sie gilt je Event: Derselbe Fahrer kann in zwei Rennen zwei Nummern
* haben, und beim Stammtisch gar keine.
*/
export class EventParticipantStore {
records = $state<EventParticipantsResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private events = getEventContext()
private riders = getRiderContext()
/** Nur Teilnehmer von Events des aktiven Teams. */
get scoped(): EventParticipantsResponse[] {
const eventIds = new Set(this.events.scoped.map((e) => e.id))
return this.records.filter((r) => eventIds.has(r.event))
}
/** Teilnehmer eines Events, nach Startnummer und dann nach Name. */
byEvent(eventId: string): EventParticipantsResponse[] {
return this.records
.filter((r) => r.event === eventId)
.sort((a, b) => {
const na = parseInt(a.number ?? '', 10)
const nb = parseInt(b.number ?? '', 10)
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb
if (Number.isFinite(na)) return -1
if (Number.isFinite(nb)) return 1
return this.riderName(a.rider).localeCompare(this.riderName(b.rider))
})
}
/** Nur die Zugesagten — wer wirklich am Start steht. */
confirmedByEvent(eventId: string): EventParticipantsResponse[] {
return this.byEvent(eventId).filter((r) => r.status === 'zugesagt')
}
byRider(riderId: string): EventParticipantsResponse[] {
return this.scoped.filter((r) => r.rider === riderId)
}
get(eventId: string, riderId: string): EventParticipantsResponse | undefined {
return this.records.find((r) => r.event === eventId && r.rider === riderId)
}
/** Startnummer eines Fahrers bei einem Event, falls vergeben. */
numberOf(eventId: string, riderId: string | undefined): string {
if (!riderId) return ''
return this.get(eventId, riderId)?.number ?? ''
}
/**
* Startnummern sind je Event eindeutig zwei gleiche Nummern in einem
* Rennen wären an der Strecke nicht auseinanderzuhalten.
*/
isNumberTaken(eventId: string, number: string, excludeId?: string): boolean {
const wanted = number.trim()
if (!wanted) return false
return this.byEvent(eventId).some(
(r) => (r.number ?? '').trim() === wanted && r.id !== excludeId,
)
}
/** Teilnahme des eingeloggten Nutzers, sofern sein Konto an einem Fahrer hängt. */
own(eventId: string): EventParticipantsResponse | undefined {
const uid = auth.user?.id
if (!uid) return undefined
const mine = new Set(this.riders.getByUser(uid).map((r) => r.id))
return this.byEvent(eventId).find((p) => mine.has(p.rider))
}
/** „#12 Max Mustermann" — ohne Nummer nur der Name. */
riderLabel(eventId: string, riderId: string | undefined): string {
if (!riderId) return 'Unbekannter Fahrer'
const name = this.riderName(riderId) || 'Unbekannter Fahrer'
const number = this.numberOf(eventId, riderId)
return number ? `#${number} ${name}` : name
}
private riderName(riderId: string): string {
const r = this.riders.getById(riderId)
if (!r) return ''
return [r.firstname, r.lastname].filter(Boolean).join(' ') || r.name || ''
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('event_participants').getFullList({
sort: '+created',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Teilnehmer'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('event_participants').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('event_participants subscribe failed:', e))
}
async add(data: {
event: string
rider: string
number?: string
status?: ParticipantStatus
}) {
return await api.collection('event_participants').create({
status: 'zugesagt' as ParticipantStatus,
...data,
created_by: auth.user?.id,
})
}
async edit(id: string, data: Partial<EventParticipantsResponse>) {
return await api.collection('event_participants').update(id, data)
}
async setStatus(id: string, status: ParticipantStatus) {
return await this.edit(id, { status } as Partial<EventParticipantsResponse>)
}
async remove(id: string) {
await api.collection('event_participants').delete(id)
}
/** Fahrer des Teams, die bei diesem Event noch fehlen. */
available(eventId: string): RidersResponse[] {
const taken = new Set(this.byEvent(eventId).map((r) => r.rider))
return this.riders.scoped.filter((r) => !taken.has(r.id))
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setEventParticipantContext() {
const store = new EventParticipantStore()
setContext(KEY, store)
return store
}
export function getEventParticipantContext(): EventParticipantStore {
return getContext<EventParticipantStore>(KEY)
}

View file

@ -0,0 +1,183 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import { getEventContext } from './events.svelte'
import { dayOf, todayISO } from '$lib/time'
import type { EventSeriesResponse } from '$lib/types'
const KEY = Symbol('eventSeries')
export type SeriesRule = 'woechentlich' | 'zweiwoechentlich' | 'monatlich'
export const SERIES_RULE_LABEL: Record<string, string> = {
woechentlich: 'wöchentlich',
zweiwoechentlich: 'alle zwei Wochen',
monatlich: 'monatlich',
}
/** Wie viele Termine ein Erzeugen-Lauf höchstens anlegt. */
const BATCH = 12
/**
* Nächste Termine einer Regel ab einem Startdatum.
*
* Gerechnet wird auf Kalendertagen, nicht auf Millisekunden: Über Sommer- und
* Winterzeit hinweg verschiebt sich eine Woche sonst um eine Stunde und ein
* Termin kippt auf den Vortag.
*/
export function occurrences(
startDay: string,
rule: SeriesRule,
count: number,
untilDay?: string,
): string[] {
if (!startDay) return []
const [y, m, d] = startDay.split('-').map(Number)
const out: string[] = []
let cursor = new Date(Date.UTC(y, m - 1, d))
for (let i = 0; i < count; i++) {
const iso = cursor.toISOString().slice(0, 10)
if (untilDay && iso > untilDay) break
out.push(iso)
if (rule === 'monatlich') {
cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate()))
} else {
const step = rule === 'zweiwoechentlich' ? 14 : 7
cursor = new Date(cursor.getTime() + step * 24 * 60 * 60 * 1000)
}
}
return out
}
export class EventSeriesStore {
records = $state<EventSeriesResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
private events = getEventContext()
get scoped(): EventSeriesResponse[] {
const teamId = this.teams.activeId
if (!teamId) return []
return this.records.filter((r) => r.team === teamId)
}
getById(id: string) {
return this.records.find((r) => r.id === id)
}
/** Termine, die aus dieser Serie entstanden sind. */
eventsOf(seriesId: string) {
return this.events.scoped.filter((e) => e.series === seriesId)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('event_series').getFullList({
sort: '+name',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Serien'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('event_series').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('event_series subscribe failed:', e))
}
async create(data: Partial<EventSeriesResponse>) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
return await api.collection('event_series').create({
...data,
team: teamId,
created_by: auth.user?.id,
})
}
async edit(id: string, data: Partial<EventSeriesResponse>) {
return await api.collection('event_series').update(id, data)
}
async remove(id: string) {
await api.collection('event_series').delete(id)
}
/**
* Legt die nächsten fälligen Termine an. Vorhandene bleiben unberührt
* erzeugt wird nur, was fehlt, damit ein zweiter Klick nichts verdoppelt.
* Zurück kommt die Zahl der neuen Termine.
*/
async generate(seriesId: string): Promise<number> {
const series = this.getById(seriesId)
if (!series) throw new Error('Serie nicht gefunden')
if (!series.starts) throw new Error('Die Serie hat kein Startdatum.')
const existing = new Set(this.eventsOf(seriesId).map((e) => dayOf(e.starts)))
const today = todayISO()
// Vergangene Termine nachträglich anzulegen hilft niemandem.
const dates = occurrences(
dayOf(series.starts),
(series.rule || 'woechentlich') as SeriesRule,
BATCH * 4,
dayOf(series.until) || undefined,
).filter((d) => d >= today && !existing.has(d))
let created = 0
for (const day of dates.slice(0, BATCH)) {
await api.collection('events').create({
name: series.name,
description: series.description,
location: series.location,
starts: day,
timing: !!series.timing,
participation: series.participation || 'offen',
series: series.id,
team: series.team,
created_by: auth.user?.id,
})
created++
}
return created
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setEventSeriesContext() {
const store = new EventSeriesStore()
setContext(KEY, store)
return store
}
export function getEventSeriesContext(): EventSeriesStore {
return getContext<EventSeriesStore>(KEY)
}

View file

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { occurrences } from './eventSeries.svelte'
describe('occurrences', () => {
it('zählt wöchentlich weiter', () => {
expect(occurrences('2026-09-02', 'woechentlich', 3)).toEqual([
'2026-09-02',
'2026-09-09',
'2026-09-16',
])
})
it('zählt zweiwöchentlich weiter', () => {
expect(occurrences('2026-09-02', 'zweiwoechentlich', 3)).toEqual([
'2026-09-02',
'2026-09-16',
'2026-09-30',
])
})
it('zählt monatlich weiter', () => {
expect(occurrences('2026-01-15', 'monatlich', 3)).toEqual([
'2026-01-15',
'2026-02-15',
'2026-03-15',
])
})
it('bleibt über die Zeitumstellung hinweg auf demselben Wochentag', () => {
// Ende Oktober 2026 wird in Europa die Uhr zurückgestellt; über
// Millisekunden gerechnet kippte der Termin sonst auf den Vortag.
const days = occurrences('2026-10-21', 'woechentlich', 3)
expect(days).toEqual(['2026-10-21', '2026-10-28', '2026-11-04'])
const weekdays = days.map((d) => new Date(`${d}T12:00:00Z`).getUTCDay())
expect(new Set(weekdays).size).toBe(1)
})
it('hört beim Enddatum auf', () => {
expect(occurrences('2026-09-02', 'woechentlich', 10, '2026-09-20')).toEqual([
'2026-09-02',
'2026-09-09',
'2026-09-16',
])
})
it('liefert nichts ohne Startdatum', () => {
expect(occurrences('', 'woechentlich', 5)).toEqual([])
})
})

View file

@ -7,6 +7,13 @@ import type { EventsResponse } from '$lib/types'
const KEY = Symbol('event')
/** Wie man zu einem Event dazukommt. */
export const PARTICIPATION_LABEL: Record<string, string> = {
offen: 'Offen',
bestaetigung: 'Mit Bestätigung',
geschlossen: 'Geschlossen',
}
export class EventStore {
records = $state<EventsResponse[]>([])
loading = $state(false)

View file

@ -6,12 +6,12 @@ import type { RidersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('rider')
function sortByNumber(records: RidersResponse[]) {
return [...records].sort((a, b) => {
const na = parseInt(a.number ?? '0', 10)
const nb = parseInt(b.number ?? '0', 10)
return na - nb
})
/**
* Der Kader wird nach Namen sortiert. Nach Startnummern ginge es nicht mehr:
* Die gelten seit 1754501300 je Event und stehen am Teilnehmer.
*/
function sortByName(records: RidersResponse[]) {
return [...records].sort((a, b) => fullName(a).localeCompare(fullName(b)))
}
export function fullName(r: RidersResponse) {
@ -48,7 +48,7 @@ export class RiderStore {
this.error = null
try {
const raw = await api.collection('riders').getFullList({ requestKey: null })
this.records = sortByNumber(raw)
this.records = sortByName(raw)
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Fahrer'
console.error(e)
@ -62,10 +62,10 @@ export class RiderStore {
api.collection('riders').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) {
this.records = sortByNumber([...this.records, e.record])
this.records = sortByName([...this.records, e.record])
} else if (e.action === 'update' && idx !== -1) {
this.records[idx] = e.record
this.records = sortByNumber(this.records)
this.records = sortByName(this.records)
} else if (e.action === 'delete' && idx !== -1) {
this.records = this.records.filter((r) => r.id !== e.record.id)
}
@ -88,7 +88,7 @@ export class RiderStore {
return new Promise<void>((resolve, reject) => {
app.confirm.request({
title: 'Fahrer löschen?',
text: `Möchten Sie „#${record.number} ${fullName(record)}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
text: `Möchten Sie „${fullName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`,
yes: async () => {
try {
await api.collection('riders').delete(record.id)
@ -139,22 +139,11 @@ export class RiderStore {
return user
}
getByEvent(eventId: string) {
return this.scoped.filter((r) => r.event === eventId)
}
isNumberTaken(number: string, eventId: string, excludeId?: string) {
return this.scoped.some(
(r) => r.number === number && r.event === eventId && r.id !== excludeId,
)
}
get filtered() {
const q = this.searchQuery.toLowerCase().trim()
if (!q) return this.scoped
return this.scoped.filter((r) => {
return (
(r.number ?? '').toLowerCase().includes(q) ||
(r.firstname ?? '').toLowerCase().includes(q) ||
(r.lastname ?? '').toLowerCase().includes(q) ||
(r.name ?? '').toLowerCase().includes(q)

View file

@ -112,6 +112,20 @@ export class TeamStore {
return await api.collection('teams').update(id, { name })
}
/**
* Logo setzen oder entfernen. Mit Datei als FormData ein leeres Feld im
* FormData würde ein vorhandenes Bild löschen, deshalb der zweite Weg.
*/
async setLogo(id: string, file: File | null) {
if (!file) {
return await api.collection('teams').update(id, { logo: null })
}
const data = new FormData()
data.append('logo', file)
return await api.collection('teams').update(id, data)
}
/** Mitglied hinzufügen (Owner/Admin only) */
async addMember(teamId: string, userId: string) {
const team = this.records.find((t) => t.id === teamId)

View file

@ -0,0 +1,104 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import { isRecent } from '$lib/time'
import type { TrailCommentsResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('trailComment')
/** Kommentar samt Verfasser, sofern dessen Datensatz sichtbar ist. */
export type TrailComment = Omit<TrailCommentsResponse, 'expand'> & {
expand?: { created_by?: UsersResponse }
}
/**
* Kommentare lagen bisher direkt in der Trail-Seite als es sie nur dort
* gab, war das die einfachere Lösung. Die Trail-Liste zeigt jetzt ebenfalls
* ihre Anzahl, und zwei Ladewege auf dieselben Daten wären einer zu viel.
*/
export class TrailCommentStore {
records = $state<TrailComment[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
get scoped(): TrailComment[] {
const teamId = this.teams.activeId
if (!teamId) return []
return this.records.filter((r) => r.team === teamId)
}
/** Kommentare eines Trails, jüngste zuerst */
byTrail(trailId: string): TrailComment[] {
return this.scoped
.filter((r) => r.trail === trailId)
.sort((a, b) => (a.created < b.created ? 1 : -1))
}
/** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */
hasRecent(trailId: string, days?: number): boolean {
return this.byTrail(trailId).some((r) => isRecent(r.created, days))
}
async load() {
this.loading = true
this.error = null
try {
this.records = (await api.collection('trail_comments').getFullList({
sort: '-created',
expand: 'created_by',
requestKey: null,
})) as TrailComment[]
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Kommentare'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('trail_comments').subscribe('*', (e) => {
const record = e.record as TrailComment
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 !== record.id)
}, { expand: 'created_by' }).then((unsub) => {
this.unsubscribe = unsub
}).catch((e) => console.warn('trail_comments subscribe failed:', e))
}
async create(trailId: string, teamId: string, text: string) {
return await api.collection('trail_comments').create({
trail: trailId,
team: teamId,
text: text.trim(),
created_by: auth.user?.id,
})
}
async remove(id: string) {
await api.collection('trail_comments').delete(id)
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setTrailCommentContext() {
const store = new TrailCommentStore()
setContext(KEY, store)
return store
}
export function getTrailCommentContext(): TrailCommentStore {
return getContext<TrailCommentStore>(KEY)
}

View file

@ -1,6 +1,7 @@
import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte'
import { isRecent } from '$lib/time'
import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('trailMarker')
@ -28,6 +29,11 @@ export class TrailMarkerStore {
.sort((a, b) => (a.created < b.created ? 1 : -1))
}
/** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */
hasRecent(trailId: string, days?: number): boolean {
return this.byTrail(trailId).some((r) => isRecent(r.created, days))
}
/** Nur die offenen — was auf der Karte auffallen soll */
openByTrail(trailId: string): TrailMarker[] {
return this.byTrail(trailId).filter((r) => !r.resolved)

View file

@ -70,3 +70,19 @@ export function formatDateRange(starts: string | undefined, ends: string | undef
return `${from} ${to}`
}
/**
* Standardfenster für neu" in Übersichten. Zwei Wochen, weil ein Trail
* selten öfter befahren wird kürzer wäre die Hervorhebung meist leer.
*/
export const RECENT_DAYS = 14
/** Liegt der Zeitpunkt innerhalb der letzten `days` Tage? */
export function isRecent(value: string | undefined, days = RECENT_DAYS): boolean {
if (!value) return false
const then = new Date(value).getTime()
if (!Number.isFinite(then)) return false
return Date.now() - then <= days * 24 * 60 * 60 * 1000
}

View file

@ -6,6 +6,8 @@ import type PocketBase from 'pocketbase'
import type { RecordService } from 'pocketbase'
export enum Collections {
EventParticipants = "event_participants",
EventSeries = "event_series",
Events = "events",
Riders = "riders",
Runs = "runs",
@ -46,28 +48,78 @@ export type AuthSystemFields<T = unknown> = {
// Record types for each collection
export enum EventParticipantsStatusOptions {
"angefragt" = "angefragt",
"zugesagt" = "zugesagt",
"abgesagt" = "abgesagt",
}
export type EventParticipantsRecord = {
created?: IsoDateString
created_by?: RecordIdString
event: RecordIdString
id: string
number?: string
rider: RecordIdString
status?: EventParticipantsStatusOptions
updated?: IsoDateString
}
export enum EventSeriesRuleOptions {
"woechentlich" = "woechentlich",
"zweiwoechentlich" = "zweiwoechentlich",
"monatlich" = "monatlich",
}
export enum EventSeriesParticipationOptions {
"offen" = "offen",
"bestaetigung" = "bestaetigung",
"geschlossen" = "geschlossen",
}
export type EventSeriesRecord = {
created?: IsoDateString
created_by?: RecordIdString
description?: string
id: string
location?: string
name: string
participation?: EventSeriesParticipationOptions
rule?: EventSeriesRuleOptions
starts?: IsoDateString
team: RecordIdString
timing?: boolean
until?: IsoDateString
updated?: IsoDateString
}
export enum EventsParticipationOptions {
"offen" = "offen",
"bestaetigung" = "bestaetigung",
"geschlossen" = "geschlossen",
}
export type EventsRecord = {
created?: IsoDateString
created_by?: RecordIdString
description?: string
ends?: IsoDateString
gps?: string
id: string
location?: string
name?: string
participation?: EventsParticipationOptions
series?: RecordIdString
starts?: IsoDateString
team?: RecordIdString
timing?: boolean
updated?: IsoDateString
}
export type RidersRecord = {
avatar?: string
created?: IsoDateString
event?: RecordIdString
firstname?: string
id: string
lastname?: string
name?: string
number?: string
team?: RecordIdString
updated?: IsoDateString
user?: RecordIdString
@ -86,6 +138,7 @@ export type TeamsRecord = {
admins?: RecordIdString[]
created?: IsoDateString
id: string
logo?: string
name: string
owner: RecordIdString
updated?: IsoDateString
@ -210,6 +263,8 @@ export type UsersRecord = {
}
// Response types include system fields and match responses from the PocketBase API
export type EventParticipantsResponse<Texpand = unknown> = Required<EventParticipantsRecord> & BaseSystemFields<Texpand>
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
@ -225,6 +280,8 @@ export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSyste
// Types containing all Records and Responses, useful for creating typing helper functions
export type CollectionRecords = {
event_participants: EventParticipantsRecord
event_series: EventSeriesRecord
events: EventsRecord
riders: RidersRecord
runs: RunsRecord
@ -239,6 +296,8 @@ export type CollectionRecords = {
}
export type CollectionResponses = {
event_participants: EventParticipantsResponse
event_series: EventSeriesResponse
events: EventsResponse
riders: RidersResponse
runs: RunsResponse
@ -256,6 +315,8 @@ export type CollectionResponses = {
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
export type TypedPocketBase = PocketBase & {
collection(idOrName: 'event_participants'): RecordService<EventParticipantsResponse>
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
collection(idOrName: 'events'): RecordService<EventsResponse>
collection(idOrName: 'riders'): RecordService<RidersResponse>
collection(idOrName: 'runs'): RecordService<RunsResponse>

View file

@ -1,16 +1,19 @@
<script lang="ts">
import { auth } from '$lib/stores/pocketbase.svelte'
import { goto } from '$app/navigation'
import { app } from '$lib/stores/app.svelte'
import { app, tooltip } from '$lib/stores/app.svelte'
import { setTeamContext } from '$lib/stores/teams.svelte'
import { setEventContext } from '$lib/stores/events.svelte'
import { setRunContext } from '$lib/stores/runs.svelte'
import { setRiderContext } from '$lib/stores/riders.svelte'
import { setTimeContext } from '$lib/stores/times.svelte'
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { setEventSeriesContext } from '$lib/stores/eventSeries.svelte'
import { setTrailContext } from '$lib/stores/trails.svelte'
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { setTrailCommentContext } from '$lib/stores/trailComments.svelte'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte'
@ -30,10 +33,14 @@
const runs = setRunContext()
const riders = setRiderContext()
const times = setTimeContext()
// Nach events und riders: Der Store greift im Constructor auf beide zu.
const participants = setEventParticipantContext()
const series = setEventSeriesContext()
const trails = setTrailContext()
const trailVersions = setTrailVersionContext()
const trailFlags = setTrailFlagContext()
const trailMarkers = setTrailMarkerContext()
const trailComments = setTrailCommentContext()
let isMobileMenuOpen = $state(false)
@ -46,17 +53,21 @@
await teams.load()
teams.subscribe()
await Promise.all([
events.load(), runs.load(), riders.load(), times.load(),
events.load(), runs.load(), riders.load(), times.load(), participants.load(), series.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
trailComments.load(),
])
events.subscribe()
runs.subscribe()
riders.subscribe()
times.subscribe()
participants.subscribe()
series.subscribe()
trails.subscribe()
trailVersions.subscribe()
trailFlags.subscribe()
trailMarkers.subscribe()
trailComments.subscribe()
})
onDestroy(() => {
@ -65,10 +76,13 @@
runs.destroy()
riders.destroy()
times.destroy()
participants.destroy()
series.destroy()
trails.destroy()
trailVersions.destroy()
trailFlags.destroy()
trailMarkers.destroy()
trailComments.destroy()
})
function handleLogout() {
@ -96,15 +110,28 @@
</script>
{#if auth.user}
<div class="min-h-screen bg-background">
<header class="border-b bg-card">
<div class="relative min-h-screen bg-background">
<!--
Das Logo als Wasserzeichen: fest in der Ecke, gedreht und sehr
blass. aria-hidden und pointer-events-none, damit es weder
vorgelesen wird noch Klicks abfängt. Im Dunkelmodus etwas
kräftiger, sonst verschwindet es im dunklen Grund.
-->
<div
aria-hidden="true"
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
>
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
</div>
<header class="relative z-10 border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<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" />
<Logo team={teams.active} class="h-11 w-11 shrink-0" />
<nav class="hidden md:flex items-center gap-1">
{#each nav as item}
@ -128,8 +155,8 @@
<div class="flex items-center justify-between gap-3">
<a
href="/dashboard/settings"
title="Profil"
class="flex min-w-0 items-center gap-2"
use:tooltip={{ content: 'Profil' }}
>
<Avatar user={auth.user} size={24} />
<span class="truncate text-sm font-medium leading-none">
@ -143,7 +170,7 @@
size="icon"
class="size-7"
onclick={toggleMode}
title="Design wechseln"
tooltip="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
@ -156,7 +183,7 @@
size="icon"
class="size-7"
onclick={handleLogout}
title="Abmelden"
tooltip="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
@ -173,7 +200,7 @@
size="icon"
class="md:hidden"
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
title="Menü"
tooltip="Menü"
>
{#if isMobileMenuOpen}
<X class="h-4 w-4" />
@ -211,14 +238,14 @@
<TeamSwitcher />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="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">
<Button variant="ghost" size="icon" onclick={handleLogout} tooltip="Abmelden">
<LogOut class="h-4 w-4" />
</Button>
</div>
@ -228,7 +255,7 @@
{/if}
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{@render children?.()}
</main>

View file

@ -1,28 +1,56 @@
<script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import {
getEventSeriesContext,
SERIES_RULE_LABEL,
occurrences,
type SeriesRule,
} from '$lib/stores/eventSeries.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 { dayOf, formatDateRange } from '$lib/time'
import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-svelte'
import { dayOf, formatDateRange, formatDay } from '$lib/time'
import { auth } from '$lib/stores/pocketbase.svelte'
import { PARTICIPATION_LABEL } from '$lib/stores/events.svelte'
import * as Select from '@/components/ui/select'
import { Calendar, MapPin, Plus, Pencil, Trash2, Timer, Users } from 'lucide-svelte'
import type { EventsResponse } from '$lib/types'
const events = getEventContext()
const runs = getRunContext()
const participants = getEventParticipantContext()
const series = getEventSeriesContext()
let showDialog = $state(false)
let editing = $state<EventsResponse | null>(null)
let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
let form = $state({
name: '',
description: '',
location: '',
starts: '',
ends: '',
timing: false,
participation: 'offen',
})
let multiDay = $state(false)
let formError = $state<string | null>(null)
function openCreate() {
editing = null
form = { name: '', description: '', location: '', starts: '', ends: '' }
form = {
name: '',
description: '',
location: '',
starts: '',
ends: '',
timing: false,
participation: 'offen',
}
multiDay = false
formError = null
showDialog = true
@ -36,6 +64,8 @@
location: e.location ?? '',
starts: dayOf(e.starts),
ends: dayOf(e.ends),
timing: !!e.timing,
participation: e.participation || 'offen',
}
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
@ -62,14 +92,109 @@
// 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)
if (editing) await events.edit(editing.id, data as any)
else await events.create({ ...data, created_by: auth.user?.id } as any)
showDialog = false
} catch (e: any) {
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.'
}
}
// --- Serien -----------------------------------------------------------
let seriesDialog = $state(false)
let seriesEditing = $state<string | null>(null)
let seriesForm = $state({
name: '',
location: '',
description: '',
rule: 'woechentlich' as SeriesRule,
starts: '',
until: '',
timing: false,
participation: 'offen',
})
let seriesError = $state<string | null>(null)
let seriesBusy = $state(false)
function openSeriesCreate() {
seriesEditing = null
seriesForm = {
name: '',
location: '',
description: '',
rule: 'woechentlich',
starts: '',
until: '',
timing: false,
participation: 'offen',
}
seriesError = null
seriesDialog = true
}
function openSeriesEdit(id: string) {
const s = series.getById(id)
if (!s) return
seriesEditing = id
seriesForm = {
name: s.name ?? '',
location: s.location ?? '',
description: s.description ?? '',
rule: (s.rule || 'woechentlich') as SeriesRule,
starts: dayOf(s.starts),
until: dayOf(s.until),
timing: !!s.timing,
participation: s.participation || 'offen',
}
seriesError = null
seriesDialog = true
}
async function saveSeries() {
if (!seriesForm.name.trim()) {
seriesError = 'Die Serie braucht einen Namen.'
return
}
if (!seriesForm.starts) {
seriesError = 'Ohne erstes Datum gibt es nichts zu wiederholen.'
return
}
seriesBusy = true
seriesError = null
try {
if (seriesEditing) await series.edit(seriesEditing, { ...seriesForm } as any)
else await series.create({ ...seriesForm } as any)
seriesDialog = false
} catch (e: any) {
seriesError = e.message ?? 'Die Serie konnte nicht gespeichert werden.'
} finally {
seriesBusy = false
}
}
async function generate(id: string) {
try {
const n = await series.generate(id)
if (n === 0) alert('Alle fälligen Termine sind bereits angelegt.')
} catch (e: any) {
alert(e.message ?? 'Die Termine konnten nicht angelegt werden.')
}
}
async function removeSeries(id: string) {
const s = series.getById(id)
if (!s) return
if (!confirm(`Serie „${s.name}" löschen? Bereits angelegte Termine bleiben bestehen.`)) return
try {
await series.remove(id)
} catch (e: any) {
alert(e.message ?? 'Die Serie konnte nicht gelöscht werden.')
}
}
const groups = $derived([
{ title: 'Läuft gerade', items: events.active },
{ title: 'Anstehend', items: events.upcoming },
@ -92,6 +217,69 @@
</Button>
</div>
<!-- Serien -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Serien</h2>
<p class="text-sm text-muted-foreground">
Wiederkehrende Termine — jeder Termin entsteht als eigenes Event und
lässt sich einzeln absagen oder verschieben.
</p>
</div>
<Button variant="outline" onclick={openSeriesCreate}>
<Plus class="h-4 w-4 mr-2" />
Serie
</Button>
</div>
{#if series.scoped.length > 0}
<div class="grid gap-3 md:grid-cols-2">
{#each series.scoped as s (s.id)}
{@const angelegt = series.eventsOf(s.id).length}
{@const naechste = occurrences(
dayOf(s.starts),
(s.rule || 'woechentlich') as SeriesRule,
3,
dayOf(s.until) || undefined,
)}
<Card>
<CardContent class="py-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="font-medium truncate">{s.name}</p>
<p class="text-sm text-muted-foreground">
{SERIES_RULE_LABEL[s.rule] ?? ''} ab {formatDay(s.starts)}
{#if s.until}bis {formatDay(s.until)}{/if}
</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" tooltip="Bearbeiten" onclick={() => openSeriesEdit(s.id)}>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" tooltip="Serie löschen" onclick={() => removeSeries(s.id)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<p class="text-xs text-muted-foreground">
{angelegt} Termin{angelegt === 1 ? '' : 'e'} angelegt
{#if naechste.length > 0}
· nächste: {naechste.map(formatDay).join(', ')}
{/if}
</p>
<Button variant="outline" size="sm" onclick={() => generate(s.id)}>
Termine anlegen
</Button>
</CardContent>
</Card>
{/each}
</div>
{/if}
</section>
{#if events.loading}
<div class="text-center py-12 text-muted-foreground">Lade Events…</div>
{:else if events.scoped.length === 0}
@ -136,16 +324,28 @@
{#if event.description && event.description !== event.name}
<p class="text-sm text-muted-foreground line-clamp-2">{event.description}</p>
{/if}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{runCount} Run{runCount === 1 ? '' : 's'}</span>
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{#if event.timing}
<span class="flex items-center gap-1">
<Timer class="size-3.5" />
{runCount} Run{runCount === 1 ? '' : 's'}
</span>
{/if}
<span class="flex items-center gap-1">
<Users class="size-3.5" />
{participants.byEvent(event.id).length}
</span>
<span class="ml-auto">
{PARTICIPATION_LABEL[event.participation] ?? ''}
</span>
</div>
</CardContent>
</a>
<div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} title="Bearbeiten">
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten">
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} title="Löschen">
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} tooltip="Löschen">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
@ -207,6 +407,37 @@
</div>
{/if}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.timing}
class="size-4 rounded border-input accent-primary"
/>
mit Zeitnahme
</label>
<p class="text-xs text-muted-foreground -mt-2">
Blendet Runs und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
unnötig.
</p>
<div class="space-y-2">
<Label>Teilnahme</Label>
<Select.Root type="single" bind:value={form.participation}>
<Select.Trigger class="w-full">
{PARTICIPATION_LABEL[form.participation] ?? 'Offen'}
</Select.Trigger>
<Select.Content>
<Select.Item value="offen">Offen — jeder trägt sich selbst ein</Select.Item>
<Select.Item value="bestaetigung">
Mit Bestätigung — Anmeldung, du bestätigst
</Select.Item>
<Select.Item value="geschlossen">
Geschlossen — du trägst Teilnehmer ein
</Select.Item>
</Select.Content>
</Select.Root>
</div>
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
@ -218,3 +449,86 @@
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Serie -->
<Dialog.Root bind:open={seriesDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{seriesEditing ? 'Serie bearbeiten' : 'Neue Serie'}</Dialog.Title>
<Dialog.Description>
Die Serie ist die Vorlage. Termine entstehen daraus als eigene Events —
Änderungen hier gelten für künftige, nicht für bereits angelegte.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="series-name">Name</Label>
<Input id="series-name" bind:value={seriesForm.name} placeholder="Stammtisch" />
</div>
<div class="space-y-2">
<Label for="series-location">Ort</Label>
<Input id="series-location" bind:value={seriesForm.location} placeholder="Hersbruck" />
</div>
<div class="space-y-2">
<Label>Rhythmus</Label>
<Select.Root type="single" bind:value={seriesForm.rule}>
<Select.Trigger class="w-full">
{SERIES_RULE_LABEL[seriesForm.rule] ?? 'wöchentlich'}
</Select.Trigger>
<Select.Content>
<Select.Item value="woechentlich">wöchentlich</Select.Item>
<Select.Item value="zweiwoechentlich">alle zwei Wochen</Select.Item>
<Select.Item value="monatlich">monatlich</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="series-starts">Erster Termin</Label>
<Input id="series-starts" type="date" bind:value={seriesForm.starts} />
</div>
<div class="space-y-2">
<Label for="series-until">Bis (optional)</Label>
<Input id="series-until" type="date" bind:value={seriesForm.until} min={seriesForm.starts} />
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={seriesForm.timing}
class="size-4 rounded border-input accent-primary"
/>
mit Zeitnahme
</label>
<div class="space-y-2">
<Label>Teilnahme</Label>
<Select.Root type="single" bind:value={seriesForm.participation}>
<Select.Trigger class="w-full">
{PARTICIPATION_LABEL[seriesForm.participation] ?? 'Offen'}
</Select.Trigger>
<Select.Content>
<Select.Item value="offen">Offen</Select.Item>
<Select.Item value="bestaetigung">Mit Bestätigung</Select.Item>
<Select.Item value="geschlossen">Geschlossen</Select.Item>
</Select.Content>
</Select.Root>
</div>
{#if seriesError}
<p class="text-sm text-destructive">{seriesError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (seriesDialog = false)}>Abbrechen</Button>
<Button onclick={saveSeries} disabled={seriesBusy}>
{seriesBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -5,7 +5,15 @@
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import {
getEventParticipantContext,
PARTICIPANT_STATUS_LABEL,
type ParticipantStatus,
} from '$lib/stores/eventParticipants.svelte'
import { auth } from '$lib/stores/pocketbase.svelte'
import TimeDialog from '$lib/components/TimeDialog.svelte'
import * as Select from '@/components/ui/select'
import { formatDateRange } from '$lib/time'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -24,18 +32,39 @@
Timer,
Trash2,
Users,
UserPlus,
Check,
X,
} from 'lucide-svelte'
import type { RidersResponse, RunsResponse } from '$lib/types'
import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const teams = getTeamContext()
const participants = getEventParticipantContext()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
/**
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
* dieselbe Regel wie in der Collection.
*/
const canManage = $derived(
!!event && (event.created_by === auth.user?.id || teams.isAdmin()),
)
/** Eigene Teilnahme, sofern das Konto an einem Fahrer hängt. */
const ownEntry = $derived(event ? participants.own(event.id) : undefined)
const ownRider = $derived(riders.getByUser(auth.user?.id ?? '')[0])
const canSignUp = $derived(
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
)
// 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) {
@ -49,42 +78,87 @@
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
let timeDialog = $state(false)
// Fahrer-Dialog
// --- Teilnehmer -------------------------------------------------------
let riderDialog = $state(false)
let ridingEdit = $state<RidersResponse | null>(null)
let riderForm = $state({ number: '', firstname: '', lastname: '' })
let editing = $state<EventParticipantsResponse | null>(null)
let form = $state({ rider: '', number: '' })
let formError = $state<string | null>(null)
function openRiderCreate() {
ridingEdit = null
riderForm = { number: '', firstname: '', lastname: '' }
const available = $derived(event ? participants.available(event.id) : [])
function openAdd() {
editing = null
form = { rider: available[0]?.id ?? '', number: '' }
formError = null
riderDialog = true
}
function openRiderEdit(r: RidersResponse) {
ridingEdit = r
riderForm = {
number: r.number ?? '',
firstname: r.firstname ?? '',
lastname: r.lastname ?? '',
}
function openEdit(p: EventParticipantsResponse) {
editing = p
form = { rider: p.rider, number: p.number ?? '' }
formError = null
riderDialog = true
}
async function saveRider() {
async function saveParticipant() {
if (!event) return
if (!riderForm.number.trim()) return alert('Startnummer ist Pflicht')
if (riders.isNumberTaken(riderForm.number, event.id, ridingEdit?.id)) {
return alert(`Startnummer ${riderForm.number} ist in diesem Event bereits vergeben`)
if (!form.rider) {
formError = 'Bitte einen Fahrer wählen.'
return
}
if (participants.isNumberTaken(event.id, form.number, editing?.id)) {
formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.`
return
}
formError = null
try {
if (ridingEdit) {
await riders.edit(ridingEdit.id, riderForm)
if (editing) {
await participants.edit(editing.id, { number: form.number.trim() })
} else {
await riders.create({ ...riderForm, event: event.id })
await participants.add({
event: event.id,
rider: form.rider,
number: form.number.trim(),
})
}
riderDialog = false
} catch (e: any) {
alert(`Fehler: ${e.message}`)
formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.'
}
}
async function setStatus(p: EventParticipantsResponse, status: ParticipantStatus) {
try {
await participants.setStatus(p.id, status)
} catch (e: any) {
alert(e.message ?? 'Der Status konnte nicht geändert werden.')
}
}
/** Selbst eintragen — im Bestätigungsmodus zunächst nur als Anfrage. */
async function signUp() {
if (!event || !ownRider) return
try {
await participants.add({
event: event.id,
rider: ownRider.id,
status: event.participation === 'bestaetigung' ? 'angefragt' : 'zugesagt',
})
} catch (e: any) {
alert(e.message ?? 'Die Anmeldung ist fehlgeschlagen.')
}
}
async function removeParticipant(p: EventParticipantsResponse) {
if (!confirm('Teilnehmer wirklich entfernen? Seine Zeiten bleiben erhalten.')) return
try {
await participants.remove(p.id)
} catch (e: any) {
alert(e.message ?? 'Der Teilnehmer konnte nicht entfernt werden.')
}
}
@ -162,7 +236,7 @@
<Button
onclick={() => (timeDialog = true)}
disabled={eventRiders.length === 0 || eventRuns.length === 0}
disabled={eventParticipants.length === 0 || eventRuns.length === 0}
>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
@ -171,42 +245,58 @@
<Separator />
<!-- Fahrer -->
<!-- Teilnehmer -->
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold flex items-center gap-2">
<Users class="h-5 w-5" />
Fahrer
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
Teilnehmer
<span class="text-muted-foreground font-normal text-base">({eventParticipants.length})</span>
</h2>
<p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
<p class="text-sm text-muted-foreground">
{#if event.participation === 'offen'}
Jedes Teammitglied kann sich selbst eintragen.
{:else if event.participation === 'bestaetigung'}
Anmeldungen werden vom Ersteller oder der Teamleitung bestätigt.
{:else}
Teilnehmer trägt der Ersteller oder die Teamleitung ein.
{/if}
</p>
</div>
<div class="flex items-center gap-2">
{#if canSignUp}
<Button variant="outline" onclick={signUp}>
<UserPlus class="h-4 w-4 mr-2" />
{event.participation === 'bestaetigung' ? 'Anfragen' : 'Ich bin dabei'}
</Button>
{/if}
{#if canManage}
<Button onclick={openAdd} disabled={available.length === 0}>
<Plus class="h-4 w-4 mr-2" />
Teilnehmer
</Button>
{/if}
</div>
{#if riders.canManage}
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Fahrer hinzufügen
</Button>
{/if}
</div>
{#if canManage && available.length === 0 && riders.scoped.length === 0}
<p class="text-sm text-muted-foreground">
Das Team hat noch keine Fahrer.
<a href="/dashboard/settings/riders" class="underline">Jetzt anlegen</a>
</p>
{/if}
<Card>
<CardContent class="p-0">
{#if eventRiders.length === 0}
{#if eventParticipants.length === 0}
<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>
{#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}
<p class="font-medium">Noch niemand dabei</p>
<p class="text-sm text-muted-foreground mt-1">
Fahrer gehören dem Team; hier steht, wer bei diesem Event
mitfährt.
</p>
</div>
{:else}
<Table.Root>
@ -214,38 +304,102 @@
<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="w-32">Status</Table.Head>
{#if event.timing}
<Table.Head class="w-40">Zeiten</Table.Head>
{/if}
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each eventRiders as rider (rider.id)}
{@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
{#each eventParticipants as p (p.id)}
{@const rider = riders.getById(p.rider)}
{@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
<Table.Cell>
<Badge variant="outline">{rider.number || '—'}</Badge>
<Badge variant="outline">{p.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
<a href={riderPath(rider.id)} class="hover:underline">
{#if fullName(rider)}
<a href={riderPath(p.rider)} class="hover:underline">
{#if rider && fullName(rider)}
{fullName(rider)}
{:else}
<span class="text-muted-foreground italic">— ohne Name —</span>
{/if}
</a>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{done} von {eventRuns.length} Runs
<Table.Cell>
<Badge
variant={p.status === 'zugesagt'
? 'default'
: p.status === 'angefragt'
? 'secondary'
: 'outline'}
>
{PARTICIPANT_STATUS_LABEL[p.status] ?? p.status}
</Badge>
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
{#if riders.canManage}
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
{#if event.timing}
<Table.Cell class="text-muted-foreground text-sm">
{done} von {eventRuns.length} Runs
</Table.Cell>
{/if}
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
{#if canManage}
{#if p.status === 'angefragt'}
<Button
variant="ghost"
size="icon"
tooltip="Bestätigen"
onclick={() => setStatus(p, 'zugesagt')}
>
<Check class="h-4 w-4 text-primary" />
</Button>
{:else if p.status === 'zugesagt'}
<Button
variant="ghost"
size="icon"
tooltip="Als abgesagt markieren"
onclick={() => setStatus(p, 'abgesagt')}
>
<X class="h-4 w-4" />
</Button>
{:else}
<Button
variant="ghost"
size="icon"
tooltip="Wieder zusagen"
onclick={() => setStatus(p, 'zugesagt')}
>
<Check class="h-4 w-4" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
tooltip="Startnummer ändern"
onclick={() => openEdit(p)}
>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
<Button
variant="ghost"
size="icon"
tooltip="Entfernen"
onclick={() => removeParticipant(p)}
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
{:else if p.id === ownEntry?.id}
<Button
variant="ghost"
size="sm"
onclick={() =>
setStatus(p, p.status === 'abgesagt' ? 'zugesagt' : 'abgesagt')}
>
{p.status === 'abgesagt' ? 'Doch dabei' : 'Absagen'}
</Button>
{/if}
</Table.Cell>
</Table.Row>
@ -257,9 +411,8 @@
</Card>
</section>
<Separator />
<!-- Runs -->
<!-- Runs — nur bei Events mit Zeitnahme -->
{#if event.timing}
<section class="space-y-4">
<div class="flex items-center justify-between">
<div>
@ -328,44 +481,61 @@
</CardContent>
</Card>
</section>
{/if}
</div>
{/if}
<!-- Fahrer-Dialog -->
<!-- Teilnehmer -->
<Dialog.Root bind:open={riderDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{ridingEdit ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
<Dialog.Title>{editing ? 'Startnummer ändern' : 'Teilnehmer hinzufügen'}</Dialog.Title>
<Dialog.Description>
Fahrer für „{event?.name}"
{#if editing}
Die Startnummer gilt nur für „{event?.name}".
{:else}
Aus dem Kader des Teams. Neue Fahrer legt die Teamleitung in den
Einstellungen an.
{/if}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault()
saveRider()
}}
class="space-y-4"
>
<div class="space-y-4">
{#if !editing}
<div class="space-y-2">
<Label>Fahrer</Label>
<Select.Root type="single" bind:value={form.rider}>
<Select.Trigger class="w-full">
{form.rider
? fullName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
: 'Fahrer wählen…'}
</Select.Trigger>
<Select.Content>
{#each available as r (r.id)}
<Select.Item value={r.id}>{fullName(r) || '— ohne Name —'}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="space-y-2">
<Label for="r-number">Startnummer *</Label>
<Input id="r-number" bind:value={riderForm.number} placeholder="42" required />
<Label for="p-number">Startnummer</Label>
<Input id="p-number" bind:value={form.number} placeholder="optional" />
<p class="text-xs text-muted-foreground">
Gilt je Event. Ohne Zeitnahme braucht es sie meist gar nicht.
</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="r-first">Vorname</Label>
<Input id="r-first" bind:value={riderForm.firstname} placeholder="Max" />
</div>
<div class="space-y-2">
<Label for="r-last">Nachname</Label>
<Input id="r-last" bind:value={riderForm.lastname} placeholder="Mustermann" />
</div>
</div>
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button type="submit">{ridingEdit ? 'Speichern' : 'Hinzufügen'}</Button>
</Dialog.Footer>
</form>
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -10,6 +10,7 @@
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
import TimeDialog from '$lib/components/TimeDialog.svelte'
@ -24,13 +25,16 @@
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
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)
// Gültig ist die Adresse, wenn der Fahrer bei diesem Event dabei ist.
const entry = $derived(event && rider ? participants.get(event.id, rider.id) : undefined)
const valid = $derived(!!event && !!rider && !!entry)
/**
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
@ -109,7 +113,9 @@
{event.name || 'Event'}
</a>
<div class="flex items-center gap-3">
<Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
{#if entry?.number}
<Badge variant="outline" class="text-lg px-3 py-1">{entry.number}</Badge>
{/if}
<h1 class="text-3xl font-bold tracking-tight">
{fullName(rider) || 'Ohne Name'}
</h1>

View file

@ -11,6 +11,7 @@
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time'
import TimeDialog from '$lib/components/TimeDialog.svelte'
@ -24,6 +25,7 @@
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
const clock = ticker()
@ -34,7 +36,8 @@
// sie von Hand zusammengebaut oder veraltet.
const valid = $derived(!!event && !!run && run.event === event.id)
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
// Am Start steht, wer beim Event zugesagt hat.
const starters = $derived(event ? participants.confirmedByEvent(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) : [])
@ -71,7 +74,7 @@
const r = rider(t.rider)
rows.push([
String(i + 1),
r?.number ?? '',
event ? participants.numberOf(event.id, t.rider) : '',
r ? fullName(r) : '',
t.formattedTime ?? '',
String(t.correction ?? 0),
@ -119,7 +122,7 @@
</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
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
</p>
</div>
@ -130,18 +133,18 @@
CSV
</Button>
{/if}
<Button onclick={() => (timeDialog = true)} disabled={eventRiders.length === 0}>
<Button onclick={() => (timeDialog = true)} disabled={starters.length === 0}>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
</div>
{#if eventRiders.length === 0}
{#if starters.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>
Für dieses Event hat noch niemand zugesagt.
<a href="/dashboard/events/{event.id}" class="underline">Teilnehmer eintragen</a>
</CardContent>
</Card>
{/if}
@ -186,7 +189,9 @@
{/if}
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{r?.number ?? '—'}</Badge>
<Badge variant="outline">
{participants.numberOf(event.id, t.rider) || '—'}
</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{#if r}
@ -223,7 +228,9 @@
{@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>
<Badge variant="outline">
{participants.numberOf(event.id, t.rider) || '—'}
</Badge>
<span class="truncate">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>

View file

@ -7,8 +7,8 @@
*/
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 { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -18,8 +18,8 @@
const teams = getTeamContext()
const riders = getRiderContext()
const events = getEventContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
let query = $state('')
@ -27,25 +27,20 @@
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),
)
return riders.scoped.filter((r) => 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
/**
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
*/
function riderPath(riderId: string) {
const latest = participants.byRider(riderId).at(-1)
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
}
</script>
@ -69,7 +64,7 @@
<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" />
<Input bind:value={query} placeholder="Name…" class="pl-9" />
</div>
{#if riders.scoped.length === 0}
@ -96,20 +91,16 @@
<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="w-28">Events</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)}
{@const path = riderPath(r.id)}
<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>
@ -118,7 +109,7 @@
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{eventName(r.event) ?? '—'}
{participants.byRider(r.id).length}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground text-sm">
{times.getByRider(r.id).length}

View file

@ -1,9 +1,11 @@
<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.
*
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder.
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
*/
import { page } from '$app/state'
import { getTeamContext } from '$lib/stores/teams.svelte'
@ -17,7 +19,7 @@
{ 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/riders', label: 'Fahrer & Konten', icon: Bike }]
: []),
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
])

View file

@ -1,162 +1,269 @@
<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.
* Fahrer und Konten an einer Stelle — nur für die Teamleitung.
*
* Jedes Teammitglied ist ein Fahrer; ein Fahrer braucht aber kein Konto.
* Beides getrennt zu pflegen hieße, dieselben Menschen zweimal anzulegen
* und die Zuordnung von Hand zu halten. Die Liste zeigt deshalb den Kader
* und daneben, wer sich anmelden kann und mit welcher Rolle.
*/
import { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import Avatar from '$lib/components/Avatar.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'
import {
Crown, KeyRound, Mail, Pencil, Plus, Shield, Trash2, Unlink, UserMinus,
} from 'lucide-svelte'
import type { RidersResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const riders = getRiderContext()
const events = getEventContext()
const participants = getEventParticipantContext()
const canManage = $derived(riders.canManage)
const roster = $derived(riders.scoped)
const team = $derived(teams.active)
const iAmOwner = $derived(teams.isOwner())
$effect(() => {
teams.loadMembers()
})
function eventName(id: string | undefined) {
if (!id) return null
return events.getById(id)?.name ?? 'Unbekanntes Event'
type Row = {
rider?: RidersResponse
userId?: string
name: string
role: 'owner' | 'admin' | 'mitglied' | null
}
// --- Fahrer anlegen und ändern ---------------------------------------
let riderDialog = $state(false)
function roleOf(userId: string | undefined): Row['role'] {
if (!userId || !team) return null
if (team.owner === userId) return 'owner'
return (team.admins ?? []).includes(userId) ? 'admin' : 'mitglied'
}
/**
* Eine Zeile je Person: alle Fahrer des Kaders, dazu Konten im Team, die
* noch an keinem Fahrer hängen — sonst verschwänden sie aus der Ansicht,
* obwohl sie Mitglied sind.
*/
const rows = $derived.by((): Row[] => {
const list: Row[] = riders.scoped.map((rider) => ({
rider,
userId: rider.user || undefined,
name: fullName(rider) || '— ohne Name —',
role: roleOf(rider.user || undefined),
}))
const linked = new Set(riders.scoped.map((r) => r.user).filter(Boolean))
for (const userId of team?.users ?? []) {
if (linked.has(userId)) continue
list.push({ userId, name: teams.userLabel(userId), role: roleOf(userId) })
}
return list.sort((a, b) => a.name.localeCompare(b.name))
})
// --- Person anlegen ---------------------------------------------------
let personDialog = $state(false)
let editing = $state<RidersResponse | null>(null)
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
let riderError = $state<string | null>(null)
let form = $state({ firstname: '', lastname: '', withAccount: false, email: '', password: '' })
let busy = $state(false)
let error = $state<string | null>(null)
function openCreate() {
editing = null
form = { number: '', firstname: '', lastname: '', event: '' }
riderError = null
riderDialog = true
form = { firstname: '', lastname: '', withAccount: false, email: '', password: '' }
error = null
personDialog = true
}
function openEdit(r: RidersResponse) {
editing = r
function openEdit(rider: RidersResponse) {
editing = rider
form = {
number: r.number ?? '',
firstname: r.firstname ?? '',
lastname: r.lastname ?? '',
event: r.event ?? '',
firstname: rider.firstname ?? '',
lastname: rider.lastname ?? '',
withAccount: false,
email: '',
password: '',
}
riderError = null
riderDialog = true
error = null
personDialog = true
}
async function saveRider() {
if (!form.number.trim()) {
riderError = 'Die Startnummer ist Pflicht.'
async function savePerson() {
if (!form.firstname.trim() && !form.lastname.trim()) {
error = 'Bitte wenigstens einen Namen angeben.'
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 (form.withAccount) {
if (!form.email.trim()) {
error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
return
}
if (form.password.length < 8) {
error = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
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
busy = true
error = null
try {
await riders.linkUser(loginRider.id, linkUserId)
loginDialog = false
const data = { firstname: form.firstname.trim(), lastname: form.lastname.trim() }
if (editing) {
await riders.edit(editing.id, data)
} else {
const rider = await riders.create(data)
if (form.withAccount) {
await riders.createLogin(rider as RidersResponse, {
email: form.email,
password: form.password,
})
}
}
personDialog = false
} catch (e: any) {
loginError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
} finally {
loginBusy = false
busy = false
}
}
async function doUnlink(r: RidersResponse) {
// --- Konto zu einem vorhandenen Fahrer --------------------------------
let accountDialog = $state(false)
let accountRider = $state<RidersResponse | null>(null)
let accountMode = $state<'neu' | 'vorhanden'>('neu')
let accountForm = $state({ email: '', password: '' })
let searchEmail = $state('')
let searching = $state(false)
let found = $state<UsersResponse | null>(null)
let accountBusy = $state(false)
let accountError = $state<string | null>(null)
function openAccount(rider: RidersResponse) {
accountRider = rider
accountMode = 'neu'
accountForm = { email: '', password: '' }
searchEmail = ''
found = null
accountError = null
accountDialog = true
}
async function createAccount() {
if (!accountRider) return
if (!accountForm.email.trim()) {
accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (accountForm.password.length < 8) {
accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
accountBusy = true
accountError = null
try {
await riders.linkUser(r.id, null)
await riders.createLogin(accountRider, { ...accountForm })
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
accountBusy = false
}
}
async function searchAccount() {
if (!searchEmail.trim()) return
searching = true
accountError = null
found = null
try {
const user = await teams.findUserByEmail(searchEmail.trim())
if (user) found = user
else accountError = `Kein Konto mit „${searchEmail}" gefunden.`
} catch (e: any) {
accountError = e.message ?? 'Die Suche ist fehlgeschlagen.'
} finally {
searching = false
}
}
async function linkFound() {
if (!accountRider || !found || !team) return
accountBusy = true
accountError = null
try {
// Erst ins Team, dann verknüpfen: Ein Konto, das nicht im Team
// ist, sähe in der Liste wie ein Mitglied aus, ohne eines zu sein.
if (!(team.users ?? []).includes(found.id)) {
await teams.addMember(team.id, found.id)
}
await riders.linkUser(accountRider.id, found.id)
accountDialog = false
} catch (e: any) {
accountError = e.message ?? 'Die Verknüpfung ist fehlgeschlagen.'
} finally {
accountBusy = false
}
}
async function unlink(rider: RidersResponse) {
if (!confirm('Verknüpfung lösen? Das Konto bleibt bestehen, der Fahrer auch.')) return
try {
await riders.linkUser(rider.id, null)
} catch (e: any) {
alert(e.message ?? 'Die Verknüpfung konnte nicht gelöst werden.')
}
}
async function doCreateLogin() {
if (!loginRider) return
/** Ein Konto ohne Fahrer nachträglich in den Kader holen. */
async function adoptAccount(userId: string) {
const user = teams.users[userId]
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
const rider = await riders.create({
firstname: user?.name ?? '',
lastname: '',
})
await riders.linkUser(rider.id, userId)
} catch (e: any) {
loginError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally {
loginBusy = false
alert(e.message ?? 'Der Fahrer konnte nicht angelegt werden.')
}
}
async function removeFromTeam(userId: string) {
if (!team) return
if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
try {
await teams.removeMember(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
}
}
async function toggleAdmin(userId: string, makeAdmin: boolean) {
if (!team) return
try {
if (makeAdmin) await teams.promoteToAdmin(team.id, userId)
else await teams.demoteFromAdmin(team.id, userId)
} catch (e: any) {
alert(e.message ?? 'Die Rolle konnte nicht geändert werden.')
}
}
</script>
@ -164,27 +271,27 @@
{#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
Fahrer und Konten verwaltet, wer das Team führt. Den Kader 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">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted-foreground">
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
anzumelden.
Jedes Teammitglied ist ein Fahrer — ein Konto braucht es dafür nicht.
Wer sich anmelden können soll, bekommt eins.
</p>
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
Fahrer anlegen
Person anlegen
</Button>
</div>
{#if roster.length === 0}
{#if rows.length === 0}
<Card>
<CardContent class="py-12 text-center text-muted-foreground">
Noch keine Fahrer im Team.
Noch niemand im Team.
</CardContent>
</Card>
{:else}
@ -193,56 +300,129 @@
<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="w-56">Konto</Table.Head>
<Table.Head class="w-32">Rolle</Table.Head>
<Table.Head class="w-24">Events</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each roster as r (r.id)}
{#each rows as row (row.rider?.id ?? row.userId)}
<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) ?? '—'}
<span class="flex items-center gap-2">
<Avatar user={row.userId ? teams.users[row.userId] : null} size={28} />
{row.name}
{#if !row.rider}
<Badge variant="outline">kein Fahrer</Badge>
{/if}
</span>
</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>
{#if row.userId}
<span class="text-sm text-muted-foreground truncate">
{teams.userLabel(row.userId)}
</span>
{:else}
<span class="text-sm text-muted-foreground">kein Konto</span>
{/if}
</Table.Cell>
<Table.Cell>
{#if row.role === 'owner'}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if row.role === 'admin'}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{:else if row.role === 'mitglied'}
<Badge variant="outline">Mitglied</Badge>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{row.rider ? participants.byRider(row.rider.id).length : '—'}
</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" />
{#if iAmOwner && row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="sm"
onclick={() => toggleAdmin(row.userId!, row.role !== 'admin')}
>
{row.role === 'admin' ? 'Admin entziehen' : 'Zu Admin'}
</Button>
{/if}
{#if row.rider && !row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Konto anlegen oder verknüpfen"
onclick={() => openAccount(row.rider!)}
>
<KeyRound class="size-4" />
</Button>
{:else if row.rider && row.userId}
<Button
variant="ghost"
size="icon"
tooltip="Verknüpfung lösen"
onclick={() => unlink(row.rider!)}
>
<Unlink class="size-4" />
</Button>
{:else if row.userId}
<Button
variant="ghost"
size="sm"
onclick={() => adoptAccount(row.userId!)}
>
Als Fahrer übernehmen
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Bearbeiten"
onclick={() => openEdit(row.rider!)}
>
<Pencil class="size-4" />
</Button>
{/if}
{#if row.userId && row.role !== 'owner'}
<Button
variant="ghost"
size="icon"
tooltip="Aus dem Team entfernen"
onclick={() => removeFromTeam(row.userId!)}
>
<UserMinus class="size-4" />
</Button>
{/if}
{#if row.rider}
<Button
variant="ghost"
size="icon"
tooltip="Fahrer löschen"
onclick={() => riders.remove(row.rider!)}
>
<Trash2 class="size-4 text-destructive" />
</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}
@ -254,61 +434,77 @@
</div>
{/if}
<!-- Fahrer anlegen/bearbeiten -->
<Dialog.Root bind:open={riderDialog}>
<!-- Person anlegen oder bearbeiten -->
<Dialog.Root bind:open={personDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Person anlegen'}</Dialog.Title>
<Dialog.Description>
{editing
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
: 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
</Dialog.Description>
</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" />
<Label for="p-first">Vorname</Label>
<Input id="p-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" />
<Label for="p-last">Nachname</Label>
<Input id="p-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 !editing}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
bind:checked={form.withAccount}
class="size-4 rounded border-input accent-primary"
/>
kann sich anmelden
</label>
{#if form.withAccount}
<div class="space-y-2">
<Label for="p-mail">E-Mail</Label>
<Input id="p-mail" type="email" bind:value={form.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="p-pw">Passwort</Label>
<Input id="p-pw" type="password" bind:value={form.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}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveRider}>{editing ? 'Speichern' : 'Anlegen'}</Button>
<Button variant="ghost" onclick={() => (personDialog = false)}>Abbrechen</Button>
<Button onclick={savePerson} disabled={busy}>
{busy ? 'Wird gespeichert …' : editing ? 'Speichern' : 'Anlegen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Konto verknüpfen oder anlegen -->
<Dialog.Root bind:open={loginDialog}>
<!-- Konto für einen vorhandenen Fahrer -->
<Dialog.Root bind:open={accountDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Konto für {loginRider ? fullName(loginRider) : 'Fahrer'}</Dialog.Title>
<Dialog.Title>
Konto für {accountRider ? fullName(accountRider) : 'Fahrer'}
</Dialog.Title>
<Dialog.Description>
Mit einem Konto kann sich die Person selbst anmelden.
</Dialog.Description>
@ -316,69 +512,75 @@
<div class="flex gap-2">
<Button
variant={loginMode === 'link' ? 'default' : 'outline'}
variant={accountMode === 'neu' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'link')}
>
Vorhandenes verknüpfen
</Button>
<Button
variant={loginMode === 'create' ? 'default' : 'outline'}
size="sm"
onclick={() => (loginMode = 'create')}
onclick={() => (accountMode = 'neu')}
>
Neues anlegen
</Button>
<Button
variant={accountMode === 'vorhanden' ? 'default' : 'outline'}
size="sm"
onclick={() => (accountMode = 'vorhanden')}
>
Vorhandenes verknüpfen
</Button>
</div>
<div class="space-y-4">
{#if loginMode === 'link'}
{#if accountMode === 'neu'}
<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}
<Label for="a-mail">E-Mail</Label>
<Input id="a-mail" type="email" bind:value={accountForm.email} placeholder="max@example.com" />
</div>
<div class="space-y-2">
<Label for="a-pw">Passwort</Label>
<Input id="a-pw" type="password" bind:value={accountForm.password} placeholder="mindestens 8 Zeichen" />
</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>
<Label for="a-search">E-Mail des Kontos</Label>
<div class="flex gap-2">
<Input
id="a-search"
type="email"
bind:value={searchEmail}
placeholder="max@example.com"
onkeydown={(e) => e.key === 'Enter' && searchAccount()}
/>
<Button variant="outline" onclick={searchAccount} disabled={searching}>
<Mail class="size-4 mr-1" />
{searching ? 'Suche …' : 'Suchen'}
</Button>
</div>
</div>
{#if found}
<div class="flex items-center gap-3 rounded-lg border bg-accent/40 p-3">
<Avatar user={found} size={36} />
<div class="min-w-0">
<p class="font-medium truncate">{found.name || found.email}</p>
<p class="text-xs text-muted-foreground">
Wird ins Team aufgenommen und verknüpft.
</p>
</div>
</div>
{/if}
{/if}
{#if loginError}
<p class="text-sm text-destructive">{loginError}</p>
{#if accountError}
<p class="text-sm text-destructive">{accountError}</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 variant="ghost" onclick={() => (accountDialog = false)}>Abbrechen</Button>
{#if accountMode === 'neu'}
<Button onclick={createAccount} disabled={accountBusy}>
{accountBusy ? 'Wird angelegt …' : 'Konto anlegen'}
</Button>
{:else}
<Button onclick={linkFound} disabled={!found || accountBusy}>Verknüpfen</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>

View file

@ -6,6 +6,10 @@
import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRiderContext } from '$lib/stores/riders.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -13,13 +17,25 @@
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'
import Logo from '$lib/components/Logo.svelte'
import {
Bike, Calendar, Image, LogOut, Plus, Route, Trash2, Users,
} from 'lucide-svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const flags = getTrailFlagContext()
const events = getEventContext()
const riders = getRiderContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
const me = $derived(api.authStore.record?.id ?? null)
const accounts = $derived(riders.scoped.filter((r) => r.user).length)
const openMarkers = $derived(
trails.scoped.reduce((sum, t) => sum + markers.openByTrail(t.id).length, 0),
)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => {
for (const t of teams.records) teams.loadMembers(t)
@ -82,44 +98,51 @@
}
}
// ---- 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)
// Kennzahlen des aktiven Teams — was hier steht, gehört dem Team und
// wandert beim Wechsel mit.
const stats = $derived([
{
label: 'Events',
value: events.scoped.length,
hint: `${events.upcoming.length} anstehend`,
icon: Calendar,
},
{ label: 'Fahrer', value: riders.scoped.length, hint: `${accounts} mit Konto`, icon: Bike },
{
label: 'Trails',
value: trails.scoped.length,
hint: `${openMarkers} offene Meldungen`,
icon: Route,
},
{ label: 'Mitglieder', value: (teams.active?.users ?? []).length, icon: Users },
])
function openMember(t: TeamsResponse) {
memberTeam = t
memberForm = { name: '', email: '', password: '' }
memberError = null
memberDialog = true
// ---- Logo ----
let logoDialog = $state(false)
let logoTeam = $state<TeamsResponse | null>(null)
let logoFile = $state<File | null>(null)
let logoBusy = $state(false)
let logoError = $state<string | null>(null)
function openLogo(t: TeamsResponse) {
logoTeam = t
logoFile = null
logoError = null
logoDialog = true
}
async function doCreateMember() {
if (!memberTeam) return
async function saveLogo(file: File | null) {
if (!logoTeam) 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
logoBusy = true
logoError = null
try {
await teams.createMember({ ...memberForm }, memberTeam.id)
memberDialog = false
await teams.setLogo(logoTeam.id, file)
logoDialog = false
} catch (e: any) {
memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.'
} finally {
memberBusy = false
logoBusy = false
}
}
@ -133,18 +156,29 @@
}
}
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="space-y-6">
{#if teams.active}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each stats as stat (stat.label)}
{@const Icon = stat.icon}
<Card>
<CardContent class="py-4">
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{stat.label}</p>
<Icon class="size-4 text-muted-foreground" />
</div>
<p class="mt-1 text-2xl font-bold">{stat.value}</p>
{#if stat.hint}
<p class="text-xs text-muted-foreground">{stat.hint}</p>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
<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
@ -175,16 +209,19 @@
<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 class="flex items-center gap-3">
<Logo team={t} class="size-10 shrink-0" />
<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>
<div class="flex flex-wrap items-center gap-2">
{#if t.id !== teams.activeId}
@ -196,9 +233,9 @@
<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 variant="outline" size="sm" onclick={() => openLogo(t)}>
<Image class="size-4 mr-1" />
Logo
</Button>
{/if}
{#if !iAmOwner}
@ -207,56 +244,16 @@
Verlassen
</Button>
{:else}
<Button variant="ghost" size="icon" title="Team löschen" onclick={() => teams.remove(t)}>
<Button variant="ghost" size="icon" tooltip="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 class="text-sm text-muted-foreground">
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
· verwaltet unter <a href="/dashboard/settings/riders" class="underline">Fahrer</a>
</CardContent>
</Card>
{/each}
@ -308,39 +305,47 @@
</Dialog.Content>
</Dialog.Root>
<!-- Mitglied anlegen -->
<Dialog.Root bind:open={memberDialog}>
<!-- Logo -->
<Dialog.Root bind:open={logoDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Mitglied anlegen</Dialog.Title>
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title>
<Dialog.Description>
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
Logo steht dort das Wappen des Stammtischs.
</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 class="flex items-center gap-4">
<Logo team={logoTeam} class="size-20 shrink-0" />
<div class="space-y-1 min-w-0">
<Label for="team-logo">Datei</Label>
<input
id="team-logo"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
</div>
</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 logoError}
<p class="text-sm text-destructive">{logoError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
<Button onclick={doCreateMember} disabled={memberBusy}>
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button>
{#if logoTeam?.logo}
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}>
Entfernen
</Button>
{/if}
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</Dialog.Footer>
</Dialog.Content>

View file

@ -5,18 +5,24 @@
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import { trailStatus } from '$lib/trailStatus'
import { tooltip } from '$lib/stores/app.svelte'
import { getTrailCommentContext } from '$lib/stores/trailComments.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import { Plus, Route, AlertTriangle } from 'lucide-svelte'
import {
Plus, Route, AlertTriangle, Ruler, TrendingUp, MapPin, MessageSquare,
} from 'lucide-svelte'
import { RECENT_DAYS } from '$lib/time'
import type { TrailVersionsResponse } from '$lib/types'
const trails = getTrailContext()
const versions = getTrailVersionContext()
const markers = getTrailMarkerContext()
const comments = getTrailCommentContext()
let dialog = $state(false)
let form = $state({ name: '', description: '' })
@ -109,6 +115,10 @@
{@const version = currentVersion(trail.id)}
{@const open = markers.openByTrail(trail.id).length}
{@const status = trailStatus(trail.status)}
{@const markerCount = markers.byTrail(trail.id).length}
{@const commentCount = comments.byTrail(trail.id).length}
{@const freshMarkers = markers.hasRecent(trail.id)}
{@const freshComments = comments.hasRecent(trail.id)}
<a href="/dashboard/trails/{trail.id}" class="block">
<Card class="h-full hover:border-primary transition-colors">
<CardHeader class="pb-3">
@ -139,15 +149,66 @@
{/if}
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatKm(version?.distance_m)}</span>
<span>{version?.ascent_m ? `${version.ascent_m} hm` : ''}</span>
<!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
<span class="flex items-center gap-1.5">
<Ruler class="size-4" />
{formatKm(version?.distance_m)}
</span>
<span class="flex items-center gap-1.5">
<TrendingUp class="size-4" />
{version?.ascent_m ? `${version.ascent_m} hm` : ''}
</span>
{#if open > 0}
<span class="flex items-center gap-1 text-amber-600 ml-auto">
<span
class="flex items-center gap-1 text-amber-600 ml-auto"
use:tooltip={{
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
}}
>
<AlertTriangle class="size-4" />
{open}
</span>
{/if}
</div>
<!--
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
-->
<div class="flex items-center gap-4 text-sm border-t pt-3">
<span
class="flex items-center gap-1.5"
class:text-muted-foreground={!freshMarkers}
class:font-medium={freshMarkers}
use:tooltip={{
content: freshMarkers
? `Marker, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Marker',
}}
>
<MapPin class="size-4" />
{markerCount}
{#if freshMarkers}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<span
class="flex items-center gap-1.5"
class:text-muted-foreground={!freshComments}
class:font-medium={freshComments}
use:tooltip={{
content: freshComments
? `Kommentare, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Kommentare',
}}
>
<MessageSquare class="size-4" />
{commentCount}
{#if freshComments}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
</div>
</CardContent>
</Card>
</a>

View file

@ -6,6 +6,7 @@
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { getTrailCommentContext } from '$lib/stores/trailComments.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
@ -30,7 +31,7 @@
} from 'lucide-svelte'
import DOMPurify from 'dompurify'
import { browser } from '$app/environment'
import type { TrailCommentsResponse, TrailVersionsResponse, UsersResponse } from '$lib/types'
import type { TrailVersionsResponse } from '$lib/types'
// Generics der JSON-Felder, wie in trails/+page.svelte — die
// Rohdaten kommen ungetypt aus PocketBase zurück.
@ -47,6 +48,7 @@
const versions = getTrailVersionContext()
const flags = getTrailFlagContext()
const markers = getTrailMarkerContext()
const commentStore = getTrailCommentContext()
const teams = getTeamContext()
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
@ -256,69 +258,17 @@
}
// --- Kommentare ------------------------------------------------------
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht.
type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }>
const comments = $derived(trail ? commentStore.byTrail(trail.id) : [])
let comments = $state<Comment[]>([])
let commentText = $state('')
let commentError = $state<string | null>(null)
let unsubComments: (() => void) | null = null
$effect(() => {
const id = trail?.id
if (!id) return
let cancelled = false
api.collection('trail_comments')
.getFullList({
filter: `trail="${id}"`,
sort: '-created',
expand: 'created_by',
requestKey: null,
})
.then((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 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
})
.catch((e) => console.warn('trail_comments subscribe failed:', e))
return () => {
cancelled = true
unsubComments?.()
unsubComments = null
}
})
async function addComment() {
if (!trail || !commentText.trim()) return
commentError = null
try {
await api.collection('trail_comments').create({
trail: trail.id,
team: trail.team,
text: commentText.trim(),
created_by: auth.user?.id,
})
await commentStore.create(trail.id, trail.team, commentText)
commentText = ''
} catch (e: any) {
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
@ -509,7 +459,7 @@
variant="ghost"
size="icon"
class="size-8"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
tooltip={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
await markers.toggleResolved(m.id)
@ -524,7 +474,7 @@
variant="ghost"
size="icon"
class="size-8"
title="Marker löschen"
tooltip="Marker löschen"
onclick={() => removeMarker(m)}
>
<Trash2 class="size-4 text-destructive" />
@ -633,7 +583,7 @@
</div>
{#if v.gpx}
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} tooltip="GPX herunterladen">
<Download class="size-4" />
</Button>
{/if}