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
This commit is contained in:
Daniel Michelberger 2026-09-02 16:03:17 +02:00
parent 45a4191f01
commit fae50d3209
23 changed files with 3818 additions and 2085 deletions

157
TODO.md
View file

@ -1,73 +1,7 @@
# 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.
### Ausfahrten: geplant, noch nicht gebaut
Ausfahrten werden **nicht** zu einem zweiten Konzept neben Events, sondern
Events bekommen Schalter. Ein Bikepark-Ausflug mit gestoppten Läufen ist beides
zugleich; eine Trennung wäre willkürlich und würde Datum, Rechte und Oberfläche
doppelt führen.
Geplant, in dieser Reihenfolge:
1. **`events.timing`** (bool). Runs und Zeitnahme erscheinen nur, wenn gesetzt.
Der wöchentliche Stammtisch braucht sie nicht.
2. **Teilnahme** als eigene Collection `event_participants` (Event, Person,
Status, Zeitpunkt) — nicht als Liste am Event, sonst hängen Status und
Zeitpunkt nirgends, und Gäste ohne Konto passen nicht hinein.
`events.participation` steuert, wie man hineinkommt:
| Wert | Bedeutung |
|---|---|
| `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 Teilnehmer ein |
3. **Serien** (`event_series`): Regel, Zeitraum und Vorlage; daraus entstehen
echte Einzeltermine. Bewusst nicht ein Datensatz mit Wiederholungsregel, aus
dem Termine nur gerechnet werden — sobald „wer kommt am 12.?" oder „diese
Woche fällt aus" beantwortet werden soll, braucht jeder Termin einen eigenen
Datensatz.
Offen: Sind Teilnehmer **Nutzerkonten** oder **Fahrer**? Für den Stammtisch
wären es Konten, für eine gemeinsame Ausfahrt eher Fahrer (die es auch ohne
Konto gibt). Beides zugleich geht, macht die Collection aber breiter.
Stand: 2026-09-02. 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
@ -83,34 +17,47 @@ Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
### Migrationen greifen beim nächsten Start
Sechs neue Migrationen liegen in `backend/pb_migrations/`:
Elf neue Migrationen liegen in `backend/pb_migrations/`:
- `1754500500_riders_user_and_admin_rules.js`
- `1754500600_events_dates_instead_of_status.js`
- `1754500700_trail_status_meta.js`
- `1754500800_trail_markers_custom.js`
- `1754500900_users_superadmin.js`
- `1754501000_teams_logo.js`
| Datei | Was sie tut |
|---|---|
| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur noch Teamleitung, `users.viewRule`/`createRule` gelockert |
| `1754500600_events_dates_instead_of_status` | `events.status` **raus**, `starts`/`ends` rein |
| `1754500700_trail_status_meta` | `trails.status_changed`, `trails.status_by` |
| `1754500800_trail_markers_custom` | `trail_markers.label`/`icon`, `flag` nicht mehr Pflicht |
| `1754500900_users_superadmin` | `users.superadmin`, gesetzt für stammtisch@dne.name |
| `1754501000_teams_logo` | `teams.logo` |
| `1754501100_trails_status_report` | Mitglieder dürfen den Trail-Zustand melden |
| `1754501200_users_list_for_invites` | `users.listRule` gelockert (Kontosuche) |
| `1754501300_event_participants` | neue Collection, **verschiebt Daten**, entfernt `riders.event`/`riders.number` |
| `1754501400_events_timing_participation` | `events.timing`/`participation`, Regeln für Selbsteintrag |
| `1754501500_event_series` | neue Collection `event_series`, `events.series` |
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).
unveränderte `users.createRule` (null = nur Superuser).
**Achtung:** Die mittlere entfernt `events.status` **samt seiner Werte**. Das
ist so gewollt (Events werden datiert statt bewertet), aber es ist ein
Datenverlust und nicht zurückzunehmen.
**Drei Dinge sind 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. `events.status` verschwindet samt seiner Werte.
2. `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.
3. 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 neu und bewusst so: Jeder Eingeloggte kann Konten auflisten
(`users.listRule`), damit sich bestehende Konten wieder 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
@ -119,6 +66,24 @@ 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 → Konto). 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
@ -128,14 +93,14 @@ 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.
- 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.
- 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.

View file

@ -11,7 +11,7 @@ migrate((app) => {
const collections = [
{
"id": "_pb_users_auth_",
"listRule": "id = @request.auth.id",
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false",
@ -451,6 +451,59 @@ migrate((app) => {
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_event_created_by",
"maxSelect": 1,
"minSelect": 0,
"name": "created_by",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "bool_event_timing",
"name": "timing",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "select_event_participation",
"maxSelect": 1,
"name": "participation",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"offen",
"bestaetigung",
"geschlossen"
]
},
{
"cascadeDelete": false,
"collectionId": "pbc_event_series",
"help": "",
"hidden": false,
"id": "relation_event_series",
"maxSelect": 1,
"minSelect": 0,
"name": "series",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "autodate2990389176",
@ -677,20 +730,6 @@ migrate((app) => {
"thumbs": [],
"type": "file"
},
{
"cascadeDelete": false,
"collectionId": "xiqlzggqxg1e2y7",
"help": "",
"hidden": false,
"id": "anbugepo",
"maxSelect": 1,
"minSelect": 0,
"name": "event",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
@ -725,21 +764,6 @@ migrate((app) => {
"system": false,
"type": "autodate"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2526027604",
"max": 0,
"min": 0,
"name": "number",
"pattern": "",
"presentable": true,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
@ -986,7 +1010,7 @@ migrate((app) => {
"listRule": "team.users.id ?= @request.auth.id",
"viewRule": "team.users.id ?= @request.auth.id",
"createRule": "team.users.id ?= @request.auth.id",
"updateRule": "stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
"updateRule": "stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || (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)",
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
"name": "trails",
"type": "base",
@ -1774,8 +1798,315 @@ migrate((app) => {
"created": "2026-08-06 14:55:19.513Z",
"updated": "2026-08-06 14:55:19.513Z",
"system": false
},
{
"id": "pbc_event_participants",
"listRule": "event.team.users.id ?= @request.auth.id",
"viewRule": "event.team.users.id ?= @request.auth.id",
"createRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id || (event.team.users.id ?= @request.auth.id && rider.user.id ?= @request.auth.id && ((event.participation = \"offen\" && status = \"zugesagt\") || (event.participation = \"bestaetigung\" && status = \"angefragt\")))",
"updateRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id || (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\")",
"deleteRule": "event.created_by.id ?= @request.auth.id || event.team.owner.id ?= @request.auth.id || event.team.admins.id ?= @request.auth.id",
"name": "event_participants",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "xiqlzggqxg1e2y7",
"help": "",
"hidden": false,
"id": "relation_participant_event",
"maxSelect": 1,
"minSelect": 0,
"name": "event",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "spgs7e4anufen9u",
"help": "",
"hidden": false,
"id": "relation_participant_rider",
"maxSelect": 1,
"minSelect": 0,
"name": "rider",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_participant_number",
"max": 20,
"min": 0,
"name": "number",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "select_participant_status",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"angefragt",
"zugesagt",
"abgesagt"
]
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_participant_created_by",
"maxSelect": 1,
"minSelect": 0,
"name": "created_by",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "autodate_created",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate_updated",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
]
],
"indexes": [],
"system": false
},
{
"id": "pbc_event_series",
"listRule": "team.users.id ?= @request.auth.id",
"viewRule": "team.users.id ?= @request.auth.id",
"createRule": "team.users.id ?= @request.auth.id",
"updateRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || created_by.id ?= @request.auth.id",
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id || created_by.id ?= @request.auth.id",
"name": "event_series",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_1568971955",
"help": "",
"hidden": false,
"id": "relation_series_team",
"maxSelect": 1,
"minSelect": 0,
"name": "team",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_series_name",
"max": 200,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_series_location",
"max": 200,
"min": 0,
"name": "location",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_series_description",
"max": 2000,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "select_series_rule",
"maxSelect": 1,
"name": "rule",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"woechentlich",
"zweiwoechentlich",
"monatlich"
]
},
{
"hidden": false,
"id": "date_series_starts",
"max": "",
"min": "",
"name": "starts",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "date_series_until",
"max": "",
"min": "",
"name": "until",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "bool_series_timing",
"name": "timing",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"help": "",
"hidden": false,
"id": "select_series_participation",
"maxSelect": 1,
"name": "participation",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"offen",
"bestaetigung",
"geschlossen"
]
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation_series_created_by",
"maxSelect": 1,
"minSelect": 0,
"name": "created_by",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "autodate_created",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate_updated",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
}
]
// importCollections erwartet das Array selbst, keinen JSON-String — die
// String-Variante heißt importCollectionsByMarshaledJSON.

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

@ -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) {
/** 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
if (!r) return 'Unbekannter Fahrer'
return `#${r.number || '—'} ${fullName(r)}`.trim()
return r ? fullName(r) : 'Unbekannter Fahrer'
}
return participants.riderLabel(eventId, riderId)
}
function runPath(runId: string | undefined) {
@ -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>

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

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

@ -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
@ -211,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>
@ -226,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
@ -240,6 +296,8 @@ export type CollectionRecords = {
}
export type CollectionResponses = {
event_participants: EventParticipantsResponse
event_series: EventSeriesResponse
events: EventsResponse
riders: RidersResponse
runs: RunsResponse
@ -257,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

@ -7,6 +7,8 @@
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'
@ -31,6 +33,9 @@
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()
@ -48,7 +53,7 @@
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(),
])
@ -56,6 +61,8 @@
runs.subscribe()
riders.subscribe()
times.subscribe()
participants.subscribe()
series.subscribe()
trails.subscribe()
trailVersions.subscribe()
trailFlags.subscribe()
@ -69,6 +76,8 @@
runs.destroy()
riders.destroy()
times.destroy()
participants.destroy()
series.destroy()
trails.destroy()
trailVersions.destroy()
trailFlags.destroy()

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,8 +324,20 @@
{#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>
@ -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>
{#if riders.canManage}
<Button onclick={openRiderCreate}>
<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" />
Fahrer hinzufügen
Teilnehmer
</Button>
{/if}
</div>
</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="font-medium">Noch niemand dabei</p>
<p class="text-sm text-muted-foreground mt-1">
Fahrer legt die Teamleitung an.
Fahrer gehören dem Team; hier steht, wer bei diesem Event
mitfährt.
</p>
{/if}
</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-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>
<Badge
variant={p.status === 'zugesagt'
? 'default'
: p.status === 'angefragt'
? 'secondary'
: 'outline'}
>
{PARTICIPANT_STATUS_LABEL[p.status] ?? p.status}
</Badge>
</Table.Cell>
{#if event.timing}
<Table.Cell class="text-muted-foreground text-sm">
{done} von {eventRuns.length} Runs
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
{#if riders.canManage}
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
{/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 for="r-number">Startnummer *</Label>
<Input id="r-number" bind:value={riderForm.number} placeholder="42" required />
<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>
<div class="grid grid-cols-2 gap-3">
{/if}
<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" />
<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>
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
</div>
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button type="submit">{ridingEdit ? 'Speichern' : 'Hinzufügen'}</Button>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</Button>
</Dialog.Footer>
</form>
</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

@ -6,7 +6,7 @@
*/
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 { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -20,7 +20,7 @@
const teams = getTeamContext()
const riders = getRiderContext()
const events = getEventContext()
const participants = getEventParticipantContext()
const canManage = $derived(riders.canManage)
const roster = $derived(riders.scoped)
@ -29,20 +29,15 @@
teams.loadMembers()
})
function eventName(id: string | undefined) {
if (!id) return null
return events.getById(id)?.name ?? 'Unbekanntes Event'
}
// --- Fahrer anlegen und ändern ---------------------------------------
let riderDialog = $state(false)
let editing = $state<RidersResponse | null>(null)
let form = $state({ number: '', firstname: '', lastname: '', event: '' })
let form = $state({ firstname: '', lastname: '' })
let riderError = $state<string | null>(null)
function openCreate() {
editing = null
form = { number: '', firstname: '', lastname: '', event: '' }
form = { firstname: '', lastname: '' }
riderError = null
riderDialog = true
}
@ -50,34 +45,24 @@
function openEdit(r: RidersResponse) {
editing = r
form = {
number: r.number ?? '',
firstname: r.firstname ?? '',
lastname: r.lastname ?? '',
event: r.event ?? '',
}
riderError = null
riderDialog = true
}
async function saveRider() {
if (!form.number.trim()) {
riderError = 'Die Startnummer ist Pflicht.'
return
}
// Startnummern sind je Event eindeutig; Fahrer ohne Event teilen sich
// den gemeinsamen Topf des Teams.
if (riders.isNumberTaken(form.number.trim(), form.event, editing?.id)) {
riderError = `Startnummer ${form.number} ist dort bereits vergeben.`
if (!form.firstname.trim() && !form.lastname.trim()) {
riderError = 'Bitte wenigstens einen Namen angeben.'
return
}
riderError = null
try {
const data = {
number: form.number.trim(),
firstname: form.firstname.trim(),
lastname: form.lastname.trim(),
event: form.event,
}
if (editing) await riders.edit(editing.id, data)
else await riders.create(data)
@ -172,8 +157,9 @@
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
Fahrer des Teams. Ein verknüpftes Konto erlaubt der Person, sich selbst
anzumelden.
Der Kader des Teams. Wer bei einem Event dabei ist und mit welcher
Startnummer, steht am Event selbst. Ein verknüpftes Konto erlaubt der
Person, sich anzumelden.
</p>
<Button onclick={openCreate}>
<Plus class="size-4 mr-2" />
@ -193,9 +179,8 @@
<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-32">Teilnahmen</Table.Head>
<Table.Head>Konto</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row>
@ -203,14 +188,11 @@
<Table.Body>
{#each roster as r (r.id)}
<Table.Row>
<Table.Cell>
<Badge variant="outline">{r.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{fullName(r) || '— ohne Name —'}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{eventName(r.event) ?? '—'}
{participants.byRider(r.id).length}
</Table.Cell>
<Table.Cell>
{#if r.user}
@ -261,10 +243,6 @@
<Dialog.Title>{editing ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="rider-number">Startnummer</Label>
<Input id="rider-number" bind:value={form.number} placeholder="42" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="rider-first">Vorname</Label>
@ -275,24 +253,6 @@
<Input id="rider-last" bind:value={form.lastname} placeholder="Mustermann" />
</div>
</div>
<div class="space-y-2">
<Label>Event</Label>
<Select.Root type="single" bind:value={form.event}>
<Select.Trigger class="w-full">
{eventName(form.event) ?? 'Keinem Event zugeordnet'}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Keinem Event zugeordnet</Select.Item>
{#each events.scoped as e (e.id)}
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Zeiten hängen am Event. Ohne Zuordnung steht der Fahrer nur im
Team-Verzeichnis.
</p>
</div>
{#if riderError}
<p class="text-sm text-destructive">{riderError}</p>
{/if}

View file

@ -15,7 +15,7 @@
import Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.svelte'
import { Crown, Image, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
import type { TeamsResponse } from '$lib/types'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext()
const flags = getTrailFlagContext()
@ -112,10 +112,14 @@
}
}
// ---- 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.
// ---- Mitglied hinzufügen ----
// Zwei Wege: ein bestehendes Konto über seine E-Mail suchen (seit
// 1754501200 wieder möglich) oder ein neues anlegen.
let memberMode = $state<'suchen' | 'anlegen'>('suchen')
let searchEmail = $state('')
let searching = $state(false)
let found = $state<UsersResponse | null>(null)
let memberDialog = $state(false)
let memberTeam = $state<TeamsResponse | null>(null)
let memberForm = $state({ name: '', email: '', password: '' })
@ -124,11 +128,51 @@
function openMember(t: TeamsResponse) {
memberTeam = t
memberMode = 'suchen'
searchEmail = ''
found = null
memberForm = { name: '', email: '', password: '' }
memberError = null
memberDialog = true
}
async function searchMember() {
if (!searchEmail.trim()) return
searching = true
memberError = null
found = null
try {
const user = await teams.findUserByEmail(searchEmail.trim())
if (user) found = user
else memberError = `Kein Konto mit „${searchEmail}" gefunden.`
} catch (e: any) {
memberError = e.message ?? 'Die Suche ist fehlgeschlagen.'
} finally {
searching = false
}
}
async function addFound() {
if (!memberTeam || !found) return
if ((memberTeam.users ?? []).includes(found.id)) {
memberError = 'Diese Person ist bereits im Team.'
return
}
memberBusy = true
memberError = null
try {
await teams.addMember(memberTeam.id, found.id)
memberDialog = false
} catch (e: any) {
memberError = e.message ?? 'Das Konto konnte nicht hinzugefügt werden.'
} finally {
memberBusy = false
}
}
async function doCreateMember() {
if (!memberTeam) return
@ -345,16 +389,61 @@
</Dialog.Content>
</Dialog.Root>
<!-- Mitglied anlegen -->
<!-- Mitglied hinzufügen -->
<Dialog.Root bind:open={memberDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Mitglied anlegen</Dialog.Title>
<Dialog.Title>Mitglied hinzufügen</Dialog.Title>
<Dialog.Description>
Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
Für „{memberTeam?.name}" — entweder ein bestehendes Konto oder ein neues.
</Dialog.Description>
</Dialog.Header>
<div class="flex gap-2">
<Button
variant={memberMode === 'suchen' ? 'default' : 'outline'}
size="sm"
onclick={() => (memberMode = 'suchen')}
>
Vorhandenes Konto
</Button>
<Button
variant={memberMode === 'anlegen' ? 'default' : 'outline'}
size="sm"
onclick={() => (memberMode = 'anlegen')}
>
Neues anlegen
</Button>
</div>
<div class="space-y-4">
{#if memberMode === 'suchen'}
<div class="space-y-2">
<Label for="search-mail">E-Mail</Label>
<div class="flex gap-2">
<Input
id="search-mail"
type="email"
bind:value={searchEmail}
placeholder="max@example.com"
onkeydown={(e) => e.key === 'Enter' && searchMember()}
/>
<Button variant="outline" onclick={searchMember} disabled={searching}>
{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">Konto gefunden</p>
</div>
</div>
{/if}
{:else}
<div class="space-y-2">
<Label for="member-name">Name</Label>
<Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
@ -370,15 +459,22 @@
Gib es der Person weiter; sie kann es unter „Profil" ändern.
</p>
</div>
{/if}
{#if memberError}
<p class="text-sm text-destructive">{memberError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
{#if memberMode === 'suchen'}
<Button onclick={addFound} disabled={!found || memberBusy}>Hinzufügen</Button>
{:else}
<Button onclick={doCreateMember} disabled={memberBusy}>
{memberBusy ? 'Wird angelegt …' : 'Anlegen'}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>