stammtisch-hersbruck/frontend/src/lib/stores/eventParticipants.svelte.ts
Daniel Michelberger 8c11095b17 feat: Ein Fahrer hat einen Namen, nicht zwei
Vorname und Nachname getrennt zu fuehren versprach eine Ordnung, die es
hier nie gab: sortiert wird ueber den ganzen Namen, angezeigt wird der
ganze Name, gesucht wird ueber beides zugleich. Dafuer musste jede Maske
zwei Felder anbieten und jede Anzeige sie wieder zusammensetzen — und
wer schlicht "Schorsch" heisst, stand vor der Frage, welches der beiden
Felder das ist.

Das Feld name gibt es an riders laengst; es lag nur brach, weil die
Anwendung firstname und lastname benutzte. Es uebernimmt deren Inhalt —
kein neues Feld, keine zweite Spalte, nichts, was auf bestehenden
Instanzen erst entstehen muesste.

Zurueck geht es nur ungenau: Die Umkehrung trennt am ersten Leerzeichen
und raet damit bei jedem Doppelvornamen falsch. Das ist der Preis dafuer,
dass die Trennung ueberhaupt verschwindet.

fullName heisst jetzt riderName und ist nur noch ein Feldzugriff — die
Funktion bleibt als die eine Stelle, an der ein namenloser Fahrer zu
einem leeren String wird statt als undefined durch die Oberflaeche zu
geistern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:10:30 +02:00

183 lines
5.7 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte'
import { getEventContext } from './events.svelte'
import { getRiderContext, riderName } 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.nameOf(a.rider).localeCompare(this.nameOf(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.nameOf(riderId) || 'Unbekannter Fahrer'
const number = this.numberOf(eventId, riderId)
return number ? `#${number} ${name}` : name
}
private nameOf(riderId: string): string {
const r = this.riders.getById(riderId)
return r ? riderName(r) : ''
}
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)
}