Compare commits

..

No commits in common. "e7892be60b9e7f51e5bbcc1c2c1325a12966f2b8" and "27d34ad0d3bfa574b467ddbfa7945ccc677ad922" have entirely different histories.

36 changed files with 2397 additions and 4629 deletions

View file

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

150
TODO.md
View file

@ -1,8 +1,40 @@
# Offene Punkte # Offene Punkte
Stand: 2026-09-02, abgeglichen mit der produktiven Instanz. Die fachlichen Stand: 2026-09-02. Was hier steht, ist bewusst nicht erledigt — entweder weil
Entscheidungen sind gefallen und gebaut; was hier steht, passiert außerhalb des es eine Entscheidung braucht oder weil es außerhalb des Repos passiert.
Repos oder wartet auf den nächsten Deploy.
## Entscheidungen
### Trail-Status: dürfen Mitglieder melden?
Anzeige und Herkunft (`status_changed`, `status_by`) sind fertig. Ändern darf
den Status weiterhin nur, wer Pate oder Team-Admin ist — `trails.updateRule`
lässt nichts anderes zu.
Sollen alle Teammitglieder melden dürfen, braucht es eine Regel, die genau ein
Feld freigibt: `team.users.id ?= @request.auth.id` kombiniert mit
`@request.body.<feld>:isset = false` für **jedes** andere Feld. Das
funktioniert, wird aber lang und bricht still, sobald ein Feld dazukommt.
Deshalb erst nach ausdrücklicher Entscheidung.
### Fahrer: Kader oder Event-Teilnehmer?
`riders.event` bindet einen Fahrer an genau ein Event. Ein in den Einstellungen
angelegter Fahrer ohne Event steht deshalb nur im Verzeichnis und taucht in
keiner Zeitnahme auf; der Dialog hat dafür ein optionales Event-Feld.
Ein echter Team-Kader, aus dem man Fahrer in Events übernimmt, wäre eine
n:m-Beziehung — also eine Schema-Änderung, keine Oberflächenfrage.
### Mitglied per E-Mail einladen
Die alte Einladung über eine E-Mail-Suche ist entfallen und durch „Mitglied
anlegen" ersetzt. Sie konnte nie funktionieren: `users.listRule` gibt nur den
eigenen Datensatz frei, die Suche lief immer ins Leere.
Ein **bestehendes** fremdes Konto in ein Team zu holen braucht eine offenere
`listRule` (z. B. `@request.auth.id != ""`). Das erlaubt allerdings, alle
Konten der Instanz aufzulisten — deshalb bewusst nicht ohne Rücksprache.
## Vor dem nächsten Deploy ## Vor dem nächsten Deploy
@ -16,49 +48,35 @@ Gelesen wird er über `$env/dynamic/public`, das heißt: Die Variable muss in de
In Coolify als normale Umgebungsvariable eintragen genügt. Fehlt sie, laufen die In Coolify als normale Umgebungsvariable eintragen genügt. Fehlt sie, laufen die
Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar. Kacheln ohne Schlüssel weiter — die Karte bleibt also sichtbar.
### Migrationen: sechs gelaufen, fünf offen ### Migrationen greifen beim nächsten Start
Abgeglichen mit `api.stammtisch-hersbruck.de` (nur gelesen): Der Backend-Deploy Fünf neue Migrationen liegen in `backend/pb_migrations/`:
hat alles bis `1754500900` mitgenommen, ab `1754501000` steht es noch aus.
| Datei | Was sie tut | Stand | - `1754500500_riders_user_and_admin_rules.js`
|---|---|---| - `1754500600_events_dates_instead_of_status.js`
| `1754500500_riders_user_and_admin_rules` | `riders.user`, Fahrerpflege nur Teamleitung, `users.viewRule`/`createRule` | gelaufen | - `1754500700_trail_status_meta.js`
| `1754500600_events_dates_instead_of_status` | `events.status` raus, `starts`/`ends` rein | gelaufen | - `1754500800_trail_markers_custom.js`
| `1754500700_trail_status_meta` | `trails.status_changed`, `status_by` | gelaufen | - `1754500900_users_superadmin.js`
| `1754500800_trail_markers_custom` | `trail_markers.label`/`icon`, `flag` optional | gelaufen |
| `1754500900_users_superadmin` | `users.superadmin` samt Schutz vor Selbstbeförderung | gelaufen |
| `1754501000_teams_logo` | `teams.logo` | **offen** |
| `1754501100_trails_status_report` | Mitglieder dürfen den Trail-Zustand melden | **offen** |
| `1754501200_users_list_for_invites` | `users.listRule` gelockert (Kontosuche) | **offen** |
| `1754501300_event_participants` | neue Collection, **verschiebt Daten**, entfernt `riders.event`/`number` | **offen** |
| `1754501400_events_timing_participation` | `events.timing`/`participation` | **offen** |
| `1754501500_event_series` | `event_series`, `events.series` | **offen** |
Bis zum nächsten Deploy laufen deshalb ins Leere: Team-Logo, Zustandsmeldung Solange sie nicht gelaufen sind, scheitert alles, was auf den neuen Regeln
durch Mitglieder, Kontosuche per E-Mail, Teilnehmerlisten, die Event-Schalter aufbaut. Am deutlichsten beim Anlegen eines Mitglieds oder eines Fahrer-Logins:
und die Serien. Die Oberfläche dafür ist da und wird beim Speichern eine **„Only superusers can perform this action"** — genau das sagt die noch
Fehlermeldung von PocketBase zeigen. unveraenderte `users.createRule` (null = nur Superuser).
**Zwei Dinge sind beim Nachziehen nicht zurückzunehmen:** **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.
1. `1754501300` legt für jeden Fahrer mit Event einen Teilnehmer an und entfernt Ebenfalls neu: Fahrer anlegen, ändern und löschen darf ab dann nur noch die
danach `riders.event` und `riders.number`. Eine Startnummer an einem Fahrer Teamleitung. Mitglieder ohne Admin-Rolle verlieren diese Möglichkeit auch auf
**ohne** Event hat danach keinen Platz mehr und geht verloren — sie war der Event-Seite.
ohnehin nirgends sichtbar, weil alle Listen nach Event filterten.
2. Bestehende Events werden auf `timing = true` und `participation =
geschlossen` gesetzt. Genau so wurden sie bisher benutzt; wer einen
Stammtisch daraus machen will, schaltet die Zeitnahme im Dialog ab.
Ebenfalls bewusst so: Nach `1754501200` kann jeder Eingeloggte Konten
auflisten, damit sich bestehende Konten per E-Mail ins Team holen lassen.
E-Mail-Adressen bleiben über `emailVisibility` verborgen.
### Typen einmal neu erzeugen ### Typen einmal neu erzeugen
`frontend/src/lib/types.d.ts` wurde bei allen Schema-Änderungen von Hand `frontend/src/lib/types.d.ts` wurde bei den letzten Schema-Änderungen von Hand
gepflegt. Grund: `pocketbase-typegen` braucht native sqlite3-Bindings, die in ergänzt (`riders.user`, `trails.status_by`/`status_changed`,
der Arbeitsumgebung nicht gebaut werden durften. `events.starts`/`ends` statt `status`). Grund: `pocketbase-typegen` braucht
native sqlite3-Bindings, die in der Arbeitsumgebung nicht gebaut werden durften.
```bash ```bash
cd frontend && npm run generate-pocketbase-types cd frontend && npm run generate-pocketbase-types
@ -67,52 +85,16 @@ cd frontend && npm run generate-pocketbase-types
Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis Die Datei sollte danach unverändert bleiben. Tut sie es nicht, gilt das Ergebnis
des Generators. des Generators.
### Regeln einmal durchspielen
Die Zugriffsregeln der neuen Collections konnten nirgends laufen — weder Docker
noch eine lokale PocketBase-Instanz standen zur Verfügung. Sie sind nach dem
Muster der vorhandenen Collections geschrieben, aber ungetestet. Nach dem Deploy
einmal ausprobieren:
- Ein einfaches Teammitglied trägt sich bei einem **offenen** Event selbst ein.
- Dasselbe bei einem Event **mit Bestätigung** — es muss als „Angefragt"
landen, nicht als „Dabei".
- Bei einem **geschlossenen** Event darf es gar nicht gehen.
- Ein Mitglied meldet einen Trail-Zustand (muss gehen) und versucht, den Namen
des Trails zu ändern (darf nicht gehen).
Der Selbsteintrag setzt außerdem voraus, dass das Konto der Person mit einem
Fahrer verknüpft ist (Einstellungen → Fahrer & Konten). Ohne Verknüpfung
erscheint der Knopf gar nicht erst.
### Logo des Stammtischs zuordnen
`teams.logo` gibt es ab `1754501000`. Solange nichts hinterlegt ist, zeigt die
App weiterhin das Wappen aus `static/` — für den Stammtisch Hersbruck sieht es
also unverändert aus. Wer es als Datei am Team haben will, lädt es nach dem
Deploy unter Einstellungen → Team → Logo hoch.
## Kleinigkeiten ## Kleinigkeiten
- Wer Regeln direkt im PocketBase-Admin ändert, hinterlässt keine Spur im Repo. - Der Melder eines Trail-Markers („wer") zeigt bis zum Deploy „Unbekannt". Die
Die Migrationen bleiben die Quelle: Sie setzen dieselben Werte noch einmal und gelockerte `users.viewRule` kommt mit `1754500500`.
überschreiben eine abweichende Handänderung stillschweigend. - Die Rolle **Superadmin** existiert seit `1754500900` und ist für
- Die Rolle **Superadmin** ist gesetzt, gibt in der App aber noch keine stammtisch@dne.name gesetzt, gibt in der App aber noch keine besonderen
besonderen Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die Rechte. Sie ist bewusst nur in PocketBase vergebbar: Die `updateRule` der
`updateRule` der users-Collection schließt `superadmin` vom Selbstsetzen aus. users-Collection schließt `superadmin` vom Selbstsetzen aus. Sobald klar ist,
Sobald klar ist, was sie dürfen soll, kommen die Regeln dazu. was sie dürfen soll, kommen die Regeln dazu.
- Flag-Typen sind **teamspezifisch**. Ein neues Team bekommt beim Anlegen den - Flag-Typen bleiben **teamspezifisch**. Ein neues Team bekommt beim Anlegen
hartkodierten Standardsatz aus `trailFlags.svelte.ts` und kann ihn frei den hartkodierten Standardsatz aus `trailFlags.svelte.ts`, den es danach frei
ändern. ändern kann. Eine appweite Verwaltung gibt es damit bewusst nicht.
- „Jedes Teammitglied ist ein Fahrer" hält die Oberfläche ein, das Backend
erzwingt es nicht: Ein Konto kann im Team sein, ohne an einem Fahrer zu
hängen. Solche Fälle stehen unter „Fahrer & Konten" mit dem Vermerk „kein
Fahrer" und lassen sich per Knopf übernehmen.
- „Als Fahrer übernehmen" rät den Namen aus dem Kontonamen und setzt ihn als
Vornamen. „Max Mustermann" auf zwei Felder aufzuteilen wäre geraten und läge
bei Doppelnamen daneben; der Name lässt sich danach korrigieren. Wenn das zu
grob ist, müsste der Knopf stattdessen nach Vor- und Nachname fragen.
- Serien erzeugen bis zu zwölf Termine je Klick und überspringen, was schon
angelegt ist. Ein Termin, der ausfällt, wird als Event gelöscht oder
verschoben — die Serie bleibt davon unberührt.
- `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal. - `NAMING.md` und `frontend/static/pedaler.svg` liegen bewusst nur lokal.

View file

@ -11,7 +11,7 @@ migrate((app) => {
const collections = [ const collections = [
{ {
"id": "_pb_users_auth_", "id": "_pb_users_auth_",
"listRule": "@request.auth.id != \"\"", "listRule": "id = @request.auth.id",
"viewRule": "@request.auth.id != \"\"", "viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"", "createRule": "@request.auth.id != \"\"",
"updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false", "updateRule": "id = @request.auth.id && @request.body.superadmin:isset = false",
@ -334,27 +334,6 @@ migrate((app) => {
"system": false, "system": false,
"type": "relation" "type": "relation"
}, },
{
"help": "",
"hidden": false,
"id": "file_team_logo",
"maxSelect": 1,
"maxSize": 5242880,
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"name": "logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": null,
"type": "file"
},
{ {
"hidden": false, "hidden": false,
"id": "autodate2990389176", "id": "autodate2990389176",
@ -451,59 +430,6 @@ migrate((app) => {
"system": false, "system": false,
"type": "text" "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, "hidden": false,
"id": "autodate2990389176", "id": "autodate2990389176",
@ -730,6 +656,20 @@ migrate((app) => {
"thumbs": [], "thumbs": [],
"type": "file" "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, "cascadeDelete": false,
"collectionId": "_pb_users_auth_", "collectionId": "_pb_users_auth_",
@ -764,6 +704,21 @@ migrate((app) => {
"system": false, "system": false,
"type": "autodate" "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": "", "autogeneratePattern": "",
"help": "", "help": "",
@ -1010,7 +965,7 @@ migrate((app) => {
"listRule": "team.users.id ?= @request.auth.id", "listRule": "team.users.id ?= @request.auth.id",
"viewRule": "team.users.id ?= @request.auth.id", "viewRule": "team.users.id ?= @request.auth.id",
"createRule": "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 || (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)", "updateRule": "stewards.id ?= @request.auth.id || team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
"deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id", "deleteRule": "team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id",
"name": "trails", "name": "trails",
"type": "base", "type": "base",
@ -1798,315 +1753,8 @@ migrate((app) => {
"created": "2026-08-06 14:55:19.513Z", "created": "2026-08-06 14:55:19.513Z",
"updated": "2026-08-06 14:55:19.513Z", "updated": "2026-08-06 14:55:19.513Z",
"system": false "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 // importCollections erwartet das Array selbst, keinen JSON-String — die
// String-Variante heißt importCollectionsByMarshaledJSON. // String-Variante heißt importCollectionsByMarshaledJSON.

View file

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

View file

@ -1,39 +0,0 @@
/// <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

@ -1,22 +0,0 @@
/// <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

@ -1,146 +0,0 @@
/// <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

@ -1,93 +0,0 @@
/// <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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,184 +0,0 @@
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

@ -1,183 +0,0 @@
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

@ -1,50 +0,0 @@
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,13 +7,6 @@ import type { EventsResponse } from '$lib/types'
const KEY = Symbol('event') 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 { export class EventStore {
records = $state<EventsResponse[]>([]) records = $state<EventsResponse[]>([])
loading = $state(false) loading = $state(false)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,56 +1,28 @@
<script lang="ts"> <script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.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 { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog' import * as Dialog from '@/components/ui/dialog'
import { dayOf, formatDateRange, formatDay } from '$lib/time' import { dayOf, formatDateRange } from '$lib/time'
import { auth } from '$lib/stores/pocketbase.svelte' import { Calendar, MapPin, Plus, Pencil, Trash2 } from 'lucide-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' import type { EventsResponse } from '$lib/types'
const events = getEventContext() const events = getEventContext()
const runs = getRunContext() const runs = getRunContext()
const participants = getEventParticipantContext()
const series = getEventSeriesContext()
let showDialog = $state(false) let showDialog = $state(false)
let editing = $state<EventsResponse | null>(null) let editing = $state<EventsResponse | null>(null)
let form = $state({ let form = $state({ name: '', description: '', location: '', starts: '', ends: '' })
name: '',
description: '',
location: '',
starts: '',
ends: '',
timing: false,
participation: 'offen',
})
let multiDay = $state(false) let multiDay = $state(false)
let formError = $state<string | null>(null) let formError = $state<string | null>(null)
function openCreate() { function openCreate() {
editing = null editing = null
form = { form = { name: '', description: '', location: '', starts: '', ends: '' }
name: '',
description: '',
location: '',
starts: '',
ends: '',
timing: false,
participation: 'offen',
}
multiDay = false multiDay = false
formError = null formError = null
showDialog = true showDialog = true
@ -64,8 +36,6 @@
location: e.location ?? '', location: e.location ?? '',
starts: dayOf(e.starts), starts: dayOf(e.starts),
ends: dayOf(e.ends), ends: dayOf(e.ends),
timing: !!e.timing,
participation: e.participation || 'offen',
} }
// Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein // Ein gespeichertes Enddatum ist das Kennzeichen für mehrtägig; ein
// eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache. // eigenes Feld dafür wäre eine zweite Wahrheit über dieselbe Sache.
@ -92,109 +62,14 @@
// dasselbe Datum zweimal. // dasselbe Datum zweimal.
const ends = multiDay && form.ends !== form.starts ? form.ends : '' const ends = multiDay && form.ends !== form.starts ? form.ends : ''
const data = { ...form, ends } const data = { ...form, ends }
if (editing) await events.edit(editing.id, data as any) if (editing) await events.edit(editing.id, data)
else await events.create({ ...data, created_by: auth.user?.id } as any) else await events.create(data)
showDialog = false showDialog = false
} catch (e: any) { } catch (e: any) {
formError = e.message ?? 'Das Event konnte nicht gespeichert werden.' 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([ const groups = $derived([
{ title: 'Läuft gerade', items: events.active }, { title: 'Läuft gerade', items: events.active },
{ title: 'Anstehend', items: events.upcoming }, { title: 'Anstehend', items: events.upcoming },
@ -217,69 +92,6 @@
</Button> </Button>
</div> </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} {#if events.loading}
<div class="text-center py-12 text-muted-foreground">Lade Events…</div> <div class="text-center py-12 text-muted-foreground">Lade Events…</div>
{:else if events.scoped.length === 0} {:else if events.scoped.length === 0}
@ -324,28 +136,16 @@
{#if event.description && event.description !== event.name} {#if event.description && event.description !== event.name}
<p class="text-sm text-muted-foreground line-clamp-2">{event.description}</p> <p class="text-sm text-muted-foreground line-clamp-2">{event.description}</p>
{/if} {/if}
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div class="flex items-center justify-between text-sm">
{#if event.timing} <span class="text-muted-foreground">{runCount} Run{runCount === 1 ? '' : 's'}</span>
<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> </div>
</CardContent> </CardContent>
</a> </a>
<div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3"> <div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten"> <Button variant="ghost" size="icon" onclick={() => openEdit(event)} title="Bearbeiten">
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} tooltip="Löschen"> <Button variant="ghost" size="icon" onclick={() => events.remove(event)} title="Löschen">
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </Button>
</div> </div>
@ -407,37 +207,6 @@
</div> </div>
{/if} {/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} {#if formError}
<p class="text-sm text-destructive">{formError}</p> <p class="text-sm text-destructive">{formError}</p>
{/if} {/if}
@ -449,86 +218,3 @@
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </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,15 +5,7 @@
import { getRunContext } from '$lib/stores/runs.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.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 TimeDialog from '$lib/components/TimeDialog.svelte'
import * as Select from '@/components/ui/select'
import { formatDateRange } from '$lib/time' import { formatDateRange } from '$lib/time'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -32,39 +24,18 @@
Timer, Timer,
Trash2, Trash2,
Users, Users,
UserPlus,
Check,
X,
} from 'lucide-svelte' } from 'lucide-svelte'
import type { EventParticipantsResponse, RunsResponse } from '$lib/types' import type { RidersResponse, RunsResponse } from '$lib/types'
const events = getEventContext() const events = getEventContext()
const runs = getRunContext() const runs = getRunContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext() const times = getTimeContext()
const teams = getTeamContext()
const participants = getEventParticipantContext()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined) const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const eventParticipants = $derived(event ? participants.byEvent(event.id) : []) const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
const eventRuns = $derived(event ? runs.getByEvent(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. // Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist. // Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
function riderPath(riderId: string) { function riderPath(riderId: string) {
@ -78,87 +49,42 @@
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen. // Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
let timeDialog = $state(false) let timeDialog = $state(false)
// --- Teilnehmer ------------------------------------------------------- // Fahrer-Dialog
let riderDialog = $state(false) let riderDialog = $state(false)
let editing = $state<EventParticipantsResponse | null>(null) let ridingEdit = $state<RidersResponse | null>(null)
let form = $state({ rider: '', number: '' }) let riderForm = $state({ number: '', firstname: '', lastname: '' })
let formError = $state<string | null>(null)
const available = $derived(event ? participants.available(event.id) : []) function openRiderCreate() {
ridingEdit = null
function openAdd() { riderForm = { number: '', firstname: '', lastname: '' }
editing = null
form = { rider: available[0]?.id ?? '', number: '' }
formError = null
riderDialog = true riderDialog = true
} }
function openEdit(p: EventParticipantsResponse) { function openRiderEdit(r: RidersResponse) {
editing = p ridingEdit = r
form = { rider: p.rider, number: p.number ?? '' } riderForm = {
formError = null number: r.number ?? '',
firstname: r.firstname ?? '',
lastname: r.lastname ?? '',
}
riderDialog = true riderDialog = true
} }
async function saveParticipant() { async function saveRider() {
if (!event) return if (!event) return
if (!riderForm.number.trim()) return alert('Startnummer ist Pflicht')
if (!form.rider) { if (riders.isNumberTaken(riderForm.number, event.id, ridingEdit?.id)) {
formError = 'Bitte einen Fahrer wählen.' return alert(`Startnummer ${riderForm.number} ist in diesem Event bereits vergeben`)
return
} }
if (participants.isNumberTaken(event.id, form.number, editing?.id)) {
formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.`
return
}
formError = null
try { try {
if (editing) { if (ridingEdit) {
await participants.edit(editing.id, { number: form.number.trim() }) await riders.edit(ridingEdit.id, riderForm)
} else { } else {
await participants.add({ await riders.create({ ...riderForm, event: event.id })
event: event.id,
rider: form.rider,
number: form.number.trim(),
})
} }
riderDialog = false riderDialog = false
} catch (e: any) { } catch (e: any) {
formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.' alert(`Fehler: ${e.message}`)
}
}
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.')
} }
} }
@ -236,7 +162,7 @@
<Button <Button
onclick={() => (timeDialog = true)} onclick={() => (timeDialog = true)}
disabled={eventParticipants.length === 0 || eventRuns.length === 0} disabled={eventRiders.length === 0 || eventRuns.length === 0}
> >
<Timer class="h-4 w-4 mr-2" /> <Timer class="h-4 w-4 mr-2" />
Zeit erfassen Zeit erfassen
@ -245,58 +171,42 @@
<Separator /> <Separator />
<!-- Teilnehmer --> <!-- Fahrer -->
<section class="space-y-4"> <section class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-semibold flex items-center gap-2"> <h2 class="text-xl font-semibold flex items-center gap-2">
<Users class="h-5 w-5" /> <Users class="h-5 w-5" />
Teilnehmer Fahrer
<span class="text-muted-foreground font-normal text-base">({eventParticipants.length})</span> <span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
</h2> </h2>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
{#if event.participation === 'offen'}
Jedes Teammitglied kann sich selbst eintragen.
{:else if event.participation === 'bestaetigung'}
Anmeldungen werden vom Ersteller oder der Teamleitung bestätigt.
{:else}
Teilnehmer trägt der Ersteller oder die Teamleitung ein.
{/if}
</p>
</div> </div>
<div class="flex items-center gap-2"> {#if riders.canManage}
{#if canSignUp} <Button onclick={openRiderCreate}>
<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" /> <Plus class="h-4 w-4 mr-2" />
Teilnehmer Fahrer hinzufügen
</Button> </Button>
{/if} {/if}
</div> </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> <Card>
<CardContent class="p-0"> <CardContent class="p-0">
{#if eventParticipants.length === 0} {#if eventRiders.length === 0}
<div class="py-12 text-center"> <div class="py-12 text-center">
<Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" /> <Users class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch niemand dabei</p> <p class="font-medium">Noch keine Fahrer</p>
{#if riders.canManage}
<p class="text-sm text-muted-foreground mt-1 mb-4">Füge den ersten Fahrer hinzu.</p>
<Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" />
Ersten Fahrer hinzufügen
</Button>
{:else}
<p class="text-sm text-muted-foreground mt-1"> <p class="text-sm text-muted-foreground mt-1">
Fahrer gehören dem Team; hier steht, wer bei diesem Event Fahrer legt die Teamleitung an.
mitfährt.
</p> </p>
{/if}
</div> </div>
{:else} {:else}
<Table.Root> <Table.Root>
@ -304,102 +214,38 @@
<Table.Row> <Table.Row>
<Table.Head class="w-20">#</Table.Head> <Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</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> <Table.Head class="w-40">Zeiten</Table.Head>
{/if}
<Table.Head class="text-right">Aktionen</Table.Head> <Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each eventParticipants as p (p.id)} {#each eventRiders as rider (rider.id)}
{@const rider = riders.getById(p.rider)} {@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
{@const done = times.getByRider(p.rider).filter((t) => t.status === 'finished').length} <Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(p.rider))}>
<Table.Cell> <Table.Cell>
<Badge variant="outline">{p.number || '—'}</Badge> <Badge variant="outline">{rider.number || '—'}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. --> <!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
<a href={riderPath(p.rider)} class="hover:underline"> <a href={riderPath(rider.id)} class="hover:underline">
{#if rider && fullName(rider)} {#if fullName(rider)}
{fullName(rider)} {fullName(rider)}
{:else} {:else}
<span class="text-muted-foreground italic">— ohne Name —</span> <span class="text-muted-foreground italic">— ohne Name —</span>
{/if} {/if}
</a> </a>
</Table.Cell> </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"> <Table.Cell class="text-muted-foreground text-sm">
{done} von {eventRuns.length} Runs {done} von {eventRuns.length} Runs
</Table.Cell> </Table.Cell>
{/if} <Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}> {#if riders.canManage}
{#if canManage} <Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
{#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" /> <Pencil class="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" onclick={() => riders.remove(rider)}>
variant="ghost"
size="icon"
tooltip="Entfernen"
onclick={() => removeParticipant(p)}
>
<Trash2 class="h-4 w-4 text-destructive" /> <Trash2 class="h-4 w-4 text-destructive" />
</Button> </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} {/if}
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
@ -411,8 +257,9 @@
</Card> </Card>
</section> </section>
<!-- Runs — nur bei Events mit Zeitnahme --> <Separator />
{#if event.timing}
<!-- Runs -->
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@ -481,61 +328,44 @@
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
{/if}
</div> </div>
{/if} {/if}
<!-- Teilnehmer --> <!-- Fahrer-Dialog -->
<Dialog.Root bind:open={riderDialog}> <Dialog.Root bind:open={riderDialog}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{editing ? 'Startnummer ändern' : 'Teilnehmer hinzufügen'}</Dialog.Title> <Dialog.Title>{ridingEdit ? 'Fahrer bearbeiten' : 'Neuer Fahrer'}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
{#if editing} Fahrer für „{event?.name}"
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.Description>
</Dialog.Header> </Dialog.Header>
<form
<div class="space-y-4"> onsubmit={(e) => {
{#if !editing} e.preventDefault()
saveRider()
}}
class="space-y-4"
>
<div class="space-y-2"> <div class="space-y-2">
<Label>Fahrer</Label> <Label for="r-number">Startnummer *</Label>
<Select.Root type="single" bind:value={form.rider}> <Input id="r-number" bind:value={riderForm.number} placeholder="42" required />
<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>
{/if} <div class="grid grid-cols-2 gap-3">
<div class="space-y-2"> <div class="space-y-2">
<Label for="p-number">Startnummer</Label> <Label for="r-first">Vorname</Label>
<Input id="p-number" bind:value={form.number} placeholder="optional" /> <Input id="r-first" bind:value={riderForm.firstname} placeholder="Max" />
<p class="text-xs text-muted-foreground"> </div>
Gilt je Event. Ohne Zeitnahme braucht es sie meist gar nicht. <div class="space-y-2">
</p> <Label for="r-last">Nachname</Label>
<Input id="r-last" bind:value={riderForm.lastname} placeholder="Mustermann" />
</div> </div>
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
</div> </div>
<Dialog.Footer> <Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button> <Button type="button" variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</Button> <Button type="submit">{ridingEdit ? 'Speichern' : 'Hinzufügen'}</Button>
</Dialog.Footer> </Dialog.Footer>
</form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View file

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

View file

@ -11,7 +11,6 @@
import { getRunContext } from '$lib/stores/runs.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time' import { TIME_STATUS_LABEL } from '$lib/time'
import TimeDialog from '$lib/components/TimeDialog.svelte' import TimeDialog from '$lib/components/TimeDialog.svelte'
@ -25,7 +24,6 @@
const runs = getRunContext() const runs = getRunContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext() const times = getTimeContext()
const participants = getEventParticipantContext()
const clock = ticker() const clock = ticker()
@ -36,8 +34,7 @@
// sie von Hand zusammengebaut oder veraltet. // sie von Hand zusammengebaut oder veraltet.
const valid = $derived(!!event && !!run && run.event === event.id) const valid = $derived(!!event && !!run && run.event === event.id)
// Am Start steht, wer beim Event zugesagt hat. const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : []) const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : []) const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : []) const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
@ -74,7 +71,7 @@
const r = rider(t.rider) const r = rider(t.rider)
rows.push([ rows.push([
String(i + 1), String(i + 1),
event ? participants.numberOf(event.id, t.rider) : '', r?.number ?? '',
r ? fullName(r) : '', r ? fullName(r) : '',
t.formattedTime ?? '', t.formattedTime ?? '',
String(t.correction ?? 0), String(t.correction ?? 0),
@ -122,7 +119,7 @@
</a> </a>
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1> <h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
<p class="mt-1 text-muted-foreground"> <p class="mt-1 text-muted-foreground">
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start {running.length} laufend · {leaderboard.length} gewertet · {eventRiders.length} Fahrer im Event
</p> </p>
</div> </div>
@ -133,18 +130,18 @@
CSV CSV
</Button> </Button>
{/if} {/if}
<Button onclick={() => (timeDialog = true)} disabled={starters.length === 0}> <Button onclick={() => (timeDialog = true)} disabled={eventRiders.length === 0}>
<Timer class="h-4 w-4 mr-2" /> <Timer class="h-4 w-4 mr-2" />
Zeit erfassen Zeit erfassen
</Button> </Button>
</div> </div>
</div> </div>
{#if starters.length === 0} {#if eventRiders.length === 0}
<Card> <Card>
<CardContent class="py-6 text-sm text-muted-foreground"> <CardContent class="py-6 text-sm text-muted-foreground">
Für dieses Event hat noch niemand zugesagt. Für dieses Event gibt es noch keine Fahrer.
<a href="/dashboard/events/{event.id}" class="underline">Teilnehmer eintragen</a> <a href="/dashboard/events/{event.id}" class="underline">Jetzt anlegen</a>
</CardContent> </CardContent>
</Card> </Card>
{/if} {/if}
@ -189,9 +186,7 @@
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Badge variant="outline"> <Badge variant="outline">{r?.number ?? '—'}</Badge>
{participants.numberOf(event.id, t.rider) || '—'}
</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if r} {#if r}
@ -228,9 +223,7 @@
{@const r = rider(t.rider)} {@const r = rider(t.rider)}
<li class="px-6 py-3 flex items-center justify-between gap-4"> <li class="px-6 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<Badge variant="outline"> <Badge variant="outline">{r?.number ?? '—'}</Badge>
{participants.numberOf(event.id, t.rider) || '—'}
</Badge>
<span class="truncate"> <span class="truncate">
{#if r} {#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a> <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 { getTeamContext } from '$lib/stores/teams.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getTimeContext } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -18,8 +18,8 @@
const teams = getTeamContext() const teams = getTeamContext()
const riders = getRiderContext() const riders = getRiderContext()
const events = getEventContext()
const times = getTimeContext() const times = getTimeContext()
const participants = getEventParticipantContext()
let query = $state('') let query = $state('')
@ -27,20 +27,25 @@
const q = query.toLowerCase().trim() const q = query.toLowerCase().trim()
if (!q) return riders.scoped if (!q) return riders.scoped
return riders.scoped.filter((r) => fullName(r).toLowerCase().includes(q)) return riders.scoped.filter(
(r) =>
(r.number ?? '').toLowerCase().includes(q) ||
fullName(r).toLowerCase().includes(q),
)
}) })
$effect(() => { $effect(() => {
teams.loadMembers() teams.loadMembers()
}) })
/** function eventName(id: string | undefined) {
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und if (!id) return null
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten. return events.getById(id)?.name ?? 'Unbekanntes Event'
*/ }
function riderPath(riderId: string) {
const latest = participants.byRider(riderId).at(-1) /** Zur Fahrerseite geht es nur mit Event — Zeiten hängen daran. */
return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null function riderPath(r: { id: string; event?: string }) {
return r.event ? `/dashboard/events/${r.event}/riders/${r.id}` : null
} }
</script> </script>
@ -64,7 +69,7 @@
<div class="relative max-w-sm"> <div class="relative max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input bind:value={query} placeholder="Name…" class="pl-9" /> <Input bind:value={query} placeholder="Nummer oder Name…" class="pl-9" />
</div> </div>
{#if riders.scoped.length === 0} {#if riders.scoped.length === 0}
@ -91,16 +96,20 @@
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head> <Table.Head>Name</Table.Head>
<Table.Head class="w-28">Events</Table.Head> <Table.Head>Event</Table.Head>
<Table.Head class="text-right w-24">Zeiten</Table.Head> <Table.Head class="text-right w-24">Zeiten</Table.Head>
<Table.Head class="w-48">Konto</Table.Head> <Table.Head class="w-48">Konto</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each roster as r (r.id)} {#each roster as r (r.id)}
{@const path = riderPath(r.id)} {@const path = riderPath(r)}
<Table.Row> <Table.Row>
<Table.Cell>
<Badge variant="outline">{r.number || '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if path} {#if path}
<a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a> <a href={path} class="hover:underline">{fullName(r) || '— ohne Name —'}</a>
@ -109,7 +118,7 @@
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-muted-foreground text-sm"> <Table.Cell class="text-muted-foreground text-sm">
{participants.byRider(r.id).length} {eventName(r.event) ?? '—'}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right text-muted-foreground text-sm"> <Table.Cell class="text-right text-muted-foreground text-sm">
{times.getByRider(r.id).length} {times.getByRider(r.id).length}

View file

@ -1,11 +1,9 @@
<script lang="ts"> <script lang="ts">
/** /**
* Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich. * Rahmen der Einstellungen: eine Überschrift, darunter Reiter je Bereich.
* * Der Reiter „Fahrer" erscheint nur für die Teamleitung — dort wird
* „Team" ist die Übersicht des Teams, „Fahrer & Konten" führt die * angelegt und verknüpft, was alle anderen nur unter „Fahrer" im
* Menschen: Jedes Mitglied ist ein Fahrer, ein Konto hat aber nicht jeder. * Hauptmenü zu sehen bekommen.
* Beides an einer Stelle, weil es sonst zwei Wahrheiten über dieselbe
* Person gäbe. Der Reiter erscheint nur für die Teamleitung.
*/ */
import { page } from '$app/state' import { page } from '$app/state'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
@ -19,7 +17,7 @@
{ href: '/dashboard/settings', label: 'Profil', icon: User }, { href: '/dashboard/settings', label: 'Profil', icon: User },
{ href: '/dashboard/settings/team', label: 'Team', icon: Users }, { href: '/dashboard/settings/team', label: 'Team', icon: Users },
...(teams.isAdmin() ...(teams.isAdmin()
? [{ href: '/dashboard/settings/riders', label: 'Fahrer & Konten', icon: Bike }] ? [{ href: '/dashboard/settings/riders', label: 'Fahrer', icon: Bike }]
: []), : []),
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag }, { href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
]) ])

View file

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

View file

@ -6,10 +6,6 @@
import { api } from '$lib/stores/pocketbase.svelte' import { api } from '$lib/stores/pocketbase.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte' import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRiderContext } from '$lib/stores/riders.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -17,25 +13,13 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog' import * as Dialog from '@/components/ui/dialog'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import Logo from '$lib/components/Logo.svelte' import { Crown, LogOut, Plus, Shield, Trash2, UserMinus, UserPlus, Users } from 'lucide-svelte'
import { import type { TeamsResponse } from '$lib/types'
Bike, Calendar, Image, LogOut, Plus, Route, Trash2, Users,
} from 'lucide-svelte'
import type { TeamsResponse, UsersResponse } from '$lib/types'
const teams = getTeamContext() const teams = getTeamContext()
const flags = getTrailFlagContext() const flags = getTrailFlagContext()
const events = getEventContext()
const riders = getRiderContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
const me = $derived(api.authStore.record?.id ?? null) const me = $derived(api.authStore.record?.id ?? null)
const accounts = $derived(riders.scoped.filter((r) => r.user).length)
const openMarkers = $derived(
trails.scoped.reduce((sum, t) => sum + markers.openByTrail(t.id).length, 0),
)
// Namen der Mitglieder nachladen — ein Team kennt nur deren IDs. // Namen der Mitglieder nachladen — ein Team kennt nur deren IDs.
$effect(() => { $effect(() => {
for (const t of teams.records) teams.loadMembers(t) for (const t of teams.records) teams.loadMembers(t)
@ -98,51 +82,44 @@
} }
} }
// Kennzahlen des aktiven Teams — was hier steht, gehört dem Team und // ---- Mitglied anlegen ----
// wandert beim Wechsel mit. // Bestehende Konten lassen sich nicht suchen: users.listRule gibt nur den
const stats = $derived([ // eigenen Datensatz frei, und das soll auch so bleiben. Ein neues Konto
{ // anzulegen deckt den Alltag ab — Vereinsmitglieder haben vorher keins.
label: 'Events', let memberDialog = $state(false)
value: events.scoped.length, let memberTeam = $state<TeamsResponse | null>(null)
hint: `${events.upcoming.length} anstehend`, let memberForm = $state({ name: '', email: '', password: '' })
icon: Calendar, let memberBusy = $state(false)
}, let memberError = $state<string | null>(null)
{ label: 'Fahrer', value: riders.scoped.length, hint: `${accounts} mit Konto`, icon: Bike },
{
label: 'Trails',
value: trails.scoped.length,
hint: `${openMarkers} offene Meldungen`,
icon: Route,
},
{ label: 'Mitglieder', value: (teams.active?.users ?? []).length, icon: Users },
])
// ---- Logo ---- function openMember(t: TeamsResponse) {
let logoDialog = $state(false) memberTeam = t
let logoTeam = $state<TeamsResponse | null>(null) memberForm = { name: '', email: '', password: '' }
let logoFile = $state<File | null>(null) memberError = null
let logoBusy = $state(false) memberDialog = true
let logoError = $state<string | null>(null)
function openLogo(t: TeamsResponse) {
logoTeam = t
logoFile = null
logoError = null
logoDialog = true
} }
async function saveLogo(file: File | null) { async function doCreateMember() {
if (!logoTeam) return if (!memberTeam) return
logoBusy = true if (!memberForm.email.trim()) {
logoError = null memberError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
return
}
if (memberForm.password.length < 8) {
memberError = 'Das Passwort braucht mindestens 8 Zeichen.'
return
}
memberBusy = true
memberError = null
try { try {
await teams.setLogo(logoTeam.id, file) await teams.createMember({ ...memberForm }, memberTeam.id)
logoDialog = false memberDialog = false
} catch (e: any) { } catch (e: any) {
logoError = e.message ?? 'Das Logo konnte nicht gespeichert werden.' memberError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
} finally { } finally {
logoBusy = false memberBusy = false
} }
} }
@ -156,29 +133,18 @@
} }
} }
async function doRemoveMember(t: TeamsResponse, userId: string) {
if (userId === t.owner) return alert('Der Owner kann nicht entfernt werden.')
if (!confirm('Mitglied wirklich entfernen?')) return
try {
await teams.removeMember(t.id, userId)
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
</script> </script>
<div class="space-y-6"> <div class="space-y-4">
{#if teams.active}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each stats as stat (stat.label)}
{@const Icon = stat.icon}
<Card>
<CardContent class="py-4">
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{stat.label}</p>
<Icon class="size-4 text-muted-foreground" />
</div>
<p class="mt-1 text-2xl font-bold">{stat.value}</p>
{#if stat.hint}
<p class="text-xs text-muted-foreground">{stat.hint}</p>
{/if}
</CardContent>
</Card>
{/each}
</div>
{/if}
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und Teams, in denen du bist. Das aktive Team bestimmt, welche Events, Fahrer und
@ -209,8 +175,6 @@
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex flex-wrap items-start justify-between gap-2"> <div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex items-center gap-3">
<Logo team={t} class="size-10 shrink-0" />
<div> <div>
<CardTitle class="flex items-center gap-2"> <CardTitle class="flex items-center gap-2">
{t.name} {t.name}
@ -222,7 +186,6 @@
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'} {(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'}
</CardDescription> </CardDescription>
</div> </div>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
{#if t.id !== teams.activeId} {#if t.id !== teams.activeId}
<Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}> <Button variant="outline" size="sm" onclick={() => teams.setActive(t.id)}>
@ -233,9 +196,9 @@
<Button variant="outline" size="sm" onclick={() => openRename(t)}> <Button variant="outline" size="sm" onclick={() => openRename(t)}>
Umbenennen Umbenennen
</Button> </Button>
<Button variant="outline" size="sm" onclick={() => openLogo(t)}> <Button variant="outline" size="sm" onclick={() => openMember(t)}>
<Image class="size-4 mr-1" /> <UserPlus class="size-4 mr-1" />
Logo Mitglied
</Button> </Button>
{/if} {/if}
{#if !iAmOwner} {#if !iAmOwner}
@ -244,16 +207,56 @@
Verlassen Verlassen
</Button> </Button>
{:else} {:else}
<Button variant="ghost" size="icon" tooltip="Team löschen" onclick={() => teams.remove(t)}> <Button variant="ghost" size="icon" title="Team löschen" onclick={() => teams.remove(t)}>
<Trash2 class="size-4 text-destructive" /> <Trash2 class="size-4 text-destructive" />
</Button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="text-sm text-muted-foreground"> <CardContent>
{(t.users ?? []).length} Mitglied{(t.users ?? []).length === 1 ? '' : 'er'} <ul class="divide-y border-t">
· verwaltet unter <a href="/dashboard/settings/riders" class="underline">Fahrer</a> {#each (t.users ?? []) as userId (userId)}
{@const isOwner = userId === t.owner}
{@const isAdm = (t.admins ?? []).includes(userId)}
<li class="py-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<Avatar user={teams.users[userId]} size={28} />
<span class="font-medium truncate">{teams.userLabel(userId)}</span>
{#if isOwner}
<Badge class="gap-1">
<Crown class="size-3" />
Owner
</Badge>
{:else if isAdm}
<Badge variant="secondary" class="gap-1">
<Shield class="size-3" />
Admin
</Badge>
{/if}
{#if userId === me}
<Badge variant="outline">Du</Badge>
{/if}
</div>
{#if iAmOwner && !isOwner}
<div class="flex items-center gap-1 shrink-0">
{#if isAdm}
<Button variant="ghost" size="sm" onclick={() => teams.demoteFromAdmin(t.id, userId)}>
Admin entziehen
</Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => teams.promoteToAdmin(t.id, userId)}>
Zu Admin
</Button>
{/if}
<Button variant="ghost" size="icon" title="Entfernen" onclick={() => doRemoveMember(t, userId)}>
<UserMinus class="size-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
</CardContent> </CardContent>
</Card> </Card>
{/each} {/each}
@ -305,47 +308,39 @@
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<!-- Logo --> <!-- Mitglied anlegen -->
<Dialog.Root bind:open={logoDialog}> <Dialog.Root bind:open={memberDialog}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Logo für „{logoTeam?.name}"</Dialog.Title> <Dialog.Title>Mitglied anlegen</Dialog.Title>
<Dialog.Description> <Dialog.Description>
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes Legt ein Konto an und nimmt es in „{memberTeam?.name}" auf.
Logo steht dort das Wappen des Stammtischs.
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-4"> <div class="space-y-2">
<Logo team={logoTeam} class="size-20 shrink-0" /> <Label for="member-name">Name</Label>
<div class="space-y-1 min-w-0"> <Input id="member-name" bind:value={memberForm.name} placeholder="Max Mustermann" />
<Label for="team-logo">Datei</Label>
<input
id="team-logo"
type="file"
accept="image/*"
class="block w-full text-sm"
onchange={(e) => (logoFile = e.currentTarget.files?.[0] ?? null)}
/>
<p class="text-xs text-muted-foreground">PNG, SVG oder WebP, bis 5 MB.</p>
</div> </div>
<div class="space-y-2">
<Label for="member-mail">E-Mail</Label>
<Input id="member-mail" type="email" bind:value={memberForm.email} placeholder="max@example.com" />
</div> </div>
<div class="space-y-2">
{#if logoError} <Label for="member-pw">Passwort</Label>
<p class="text-sm text-destructive">{logoError}</p> <Input id="member-pw" type="password" bind:value={memberForm.password} placeholder="mindestens 8 Zeichen" />
<p class="text-xs text-muted-foreground">
Gib es der Person weiter; sie kann es unter „Profil" ändern.
</p>
</div>
{#if memberError}
<p class="text-sm text-destructive">{memberError}</p>
{/if} {/if}
</div> </div>
<Dialog.Footer> <Dialog.Footer>
<Button variant="ghost" onclick={() => (logoDialog = false)}>Abbrechen</Button> <Button variant="ghost" onclick={() => (memberDialog = false)}>Abbrechen</Button>
{#if logoTeam?.logo} <Button onclick={doCreateMember} disabled={memberBusy}>
<Button variant="outline" disabled={logoBusy} onclick={() => saveLogo(null)}> {memberBusy ? 'Wird angelegt …' : 'Anlegen'}
Entfernen
</Button>
{/if}
<Button disabled={!logoFile || logoBusy} onclick={() => saveLogo(logoFile)}>
{logoBusy ? 'Wird gespeichert …' : 'Speichern'}
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>

View file

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

View file

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