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
183 lines
5.7 KiB
TypeScript
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)
|
|
}
|