stammtisch-hersbruck/frontend/src/lib/stores/events.svelte.ts
Daniel Michelberger fae50d3209 feat: Fahrer werden zum Kader, Events bekommen Teilnehmer und Serien
Damit sind alle offenen Punkte aus TODO.md gebaut.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
2026-09-02 16:03:17 +02:00

166 lines
5.1 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import { dayOf, todayISO } from '$lib/time'
import type { EventsResponse } from '$lib/types'
const KEY = Symbol('event')
/** Wie man zu einem Event dazukommt. */
export const PARTICIPATION_LABEL: Record<string, string> = {
offen: 'Offen',
bestaetigung: 'Mit Bestätigung',
geschlossen: 'Geschlossen',
}
export class EventStore {
records = $state<EventsResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
/** Records gefiltert nach aktivem Team */
get scoped(): EventsResponse[] {
const teamId = this.teams.activeId
if (!teamId) return []
return this.records.filter((r) => r.team === teamId)
}
async load() {
this.loading = true
this.error = null
try {
this.records = await api.collection('events').getFullList({
sort: '-created',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Events'
console.error(e)
} finally {
this.loading = false
}
}
subscribe() {
if (this.unsubscribe) return
api.collection('events').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records]
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('events subscribe failed:', e))
}
async create(data: Partial<EventsResponse>) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
const rec = await api.collection('events').create({ ...data, team: teamId })
return rec
}
async edit(id: string, data: Partial<EventsResponse>) {
return await api.collection('events').update(id, data)
}
remove(record: EventsResponse) {
return new Promise<void>((resolve, reject) => {
app.confirm.request({
title: 'Event löschen?',
text: `Möchten Sie das Event „${record.name || record.description}" wirklich löschen?`,
yes: async () => {
try {
await api.collection('events').delete(record.id)
app.confirm.close()
resolve()
} catch (e) {
app.confirm.close()
reject(e)
}
},
no: () => {
app.confirm.close()
resolve()
},
})
})
}
getById(id: string) {
return this.records.find((r) => r.id === id)
}
/**
* Nach Datum sortiert, das nächste zuerst. Events ohne Datum hängen
* hinten dran — sie sind noch nicht terminiert.
*/
get byDate(): EventsResponse[] {
return [...this.scoped].sort((a, b) => {
const da = dayOf(a.starts)
const db = dayOf(b.starts)
if (!da && !db) return 0
if (!da) return 1
if (!db) return -1
return da < db ? -1 : da > db ? 1 : 0
})
}
/**
* Läuft heute. Ohne Enddatum zählt der Starttag allein — genau dann ist
* ein eintägiges Event „heute".
*/
get active(): EventsResponse[] {
const today = todayISO()
return this.byDate.filter((r) => {
const from = dayOf(r.starts)
if (!from) return false
const to = dayOf(r.ends) || from
return from <= today && today <= to
})
}
/** Steht noch an. */
get upcoming(): EventsResponse[] {
const today = todayISO()
return this.byDate.filter((r) => dayOf(r.starts) > today)
}
/** Vorbei — der letzte Tag liegt hinter uns. */
get past(): EventsResponse[] {
const today = todayISO()
return this.byDate
.filter((r) => {
const from = dayOf(r.starts)
if (!from) return false
return (dayOf(r.ends) || from) < today
})
.reverse()
}
/** Noch ohne Termin. */
get undated(): EventsResponse[] {
return this.byDate.filter((r) => !dayOf(r.starts))
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe()
this.unsubscribe = null
}
}
}
export function setEventContext() {
const store = new EventStore()
setContext(KEY, store)
return store
}
export function getEventContext(): EventStore {
return getContext<EventStore>(KEY)
}