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