Termine: Wiederholungsserien (rrule.js), iCal-Subscription-Feed (ical-generator)

This commit is contained in:
rene 2026-05-20 19:58:33 +02:00
parent c23ac90d35
commit 3ac17b2645
7 changed files with 317 additions and 38 deletions

View file

@ -32,7 +32,7 @@
let einladungUrl = $state('');
let einladungKopiert = $state(false);
let vereinId = '';
let vereinId = $state('');
const bundeslaender = [
['', '—'],
@ -206,6 +206,18 @@
</div>
</section>
<section>
<h2>Kalender-Abo</h2>
<p class="sepa-hint">
Diese URL in Apple Calendar, Google Calendar oder Outlook eintragen Termine erscheinen automatisch im Handy-Kalender.
</p>
<div class="ical-url">{typeof window !== 'undefined' ? window.location.origin : ''}/api/kalender/{vereinId}</div>
<button type="button" class="btn-kopieren" onclick={async () => {
await navigator.clipboard.writeText(`${window.location.origin}/api/kalender/${vereinId}`);
}}>URL kopieren</button>
<p class="sepa-hint" style="margin-top:0.4rem">Für iOS: <strong>webcal://</strong> statt https:// verwenden öffnet direkt den Abonnieren-Dialog.</p>
</section>
{#if isAdmin()}
<section>
<h2>Durchführende</h2>
@ -328,6 +340,12 @@
margin-bottom: 1.5rem;
}
.ical-url {
font-family: monospace; font-size: 0.78rem; color: #1e293b;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
padding: 0.5rem 0.75rem; word-break: break-all; margin-bottom: 0.5rem;
}
.trainer-liste {
list-style: none; padding: 0; margin: 0 0 0.75rem;
border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import { RRule } from 'rrule';
import type { Termin, Gruppe, Verfuegbarkeit } from '$lib/types';
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
@ -12,18 +13,22 @@
let loading = $state(true);
// Formular
let showForm = $state(false);
let editId = $state<string | null>(null);
let fTitel = $state('');
let fBeschr = $state('');
let fBeginn = $state('');
let fEnde = $state('');
let fOrt = $state('');
let fGruppeIds = $state<string[]>([]);
let showForm = $state(false);
let editId = $state<string | null>(null);
let fTitel = $state('');
let fBeschr = $state('');
let fBeginn = $state('');
let fEnde = $state('');
let fOrt = $state('');
let fGruppeIds = $state<string[]>([]);
let fDurchfuehrenderId = $state('');
let saving = $state(false);
let formError = $state('');
let showDelete = $state<string | null>(null);
let fWiederholung = $state(false);
let fRhythmus = $state<'woechentlich' | 'zweıwoechentlich' | 'monatlich'>('woechentlich');
let fBis = $state('');
let saving = $state(false);
let formError = $state('');
let showDelete = $state<string | null>(null);
let deleteSerieMode = $state(false);
const now = new Date();
@ -67,6 +72,7 @@
function neuerTermin() {
editId = null; fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = '';
fOrt = ''; fGruppeIds = []; fDurchfuehrenderId = '';
fWiederholung = false; fRhythmus = 'woechentlich'; fBis = '';
formError = ''; showForm = true;
}
@ -75,28 +81,68 @@
fBeginn = toLocal(t.beginn); fEnde = toLocal(t.ende);
fOrt = t.ort ?? ''; fGruppeIds = t.gruppe_ids ?? [];
fDurchfuehrenderId = t.durchfuehrender_id ?? '';
fWiederholung = false; // Einzeltermin bearbeiten, nie die ganze Serie
formError = ''; showForm = true;
}
function generiereTerminDaten(beginn: Date, rruleStr: string | null) {
const vid = pb.authStore.record?.verein_id as string;
return {
verein_id: vid,
titel: fTitel.trim(),
beschreibung: fBeschr.trim() || null,
ort: fOrt.trim() || null,
gruppe_ids: fGruppeIds,
durchfuehrender_id: fDurchfuehrenderId || null,
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null,
rrule: rruleStr,
};
}
async function speichern() {
if (!fTitel.trim() || !fBeginn) { formError = 'Titel und Beginn sind Pflichtfelder.'; return; }
if (fWiederholung && !fBis) { formError = 'Bitte ein Enddatum für die Wiederholung angeben.'; return; }
formError = ''; saving = true;
try {
const vid = pb.authStore.record?.verein_id as string;
const data = {
verein_id: vid, titel: fTitel.trim(),
beschreibung: fBeschr.trim() || null,
beginn: fromLocal(fBeginn), ende: fEnde ? fromLocal(fEnde) : null,
ort: fOrt.trim() || null, gruppe_ids: fGruppeIds,
durchfuehrender_id: fDurchfuehrenderId || null,
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null,
};
if (editId) {
const updated = await pb.collection('termine').update<Termin>(editId, data);
termine = termine.map(t => t.id === editId ? updated : t);
if (fWiederholung && !editId) {
// Serie anlegen: rrule generieren, Einzeltermine erstellen
const dtstart = new Date(fBeginn);
const until = new Date(fBis + 'T23:59:59');
const dauer = fEnde ? new Date(fEnde).getTime() - dtstart.getTime() : 60 * 60 * 1000;
const freq = fRhythmus === 'monatlich' ? RRule.MONTHLY : RRule.WEEKLY;
const interval = fRhythmus === 'zweıwoechentlich' ? 2 : 1;
const rule = new RRule({ freq, interval, dtstart, until });
const rruleStr = `FREQ=${fRhythmus === 'monatlich' ? 'MONTHLY' : 'WEEKLY'}${interval === 2 ? ';INTERVAL=2' : ''};UNTIL=${until.toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`;
const serie_id = crypto.randomUUID();
const dates = rule.all();
const neu = await Promise.all(
dates.map(d =>
pb.collection('termine').create<Termin>({
...generiereTerminDaten(d, rruleStr),
beginn: d.toISOString(),
ende: fEnde ? new Date(d.getTime() + dauer).toISOString() : null,
serie_id,
}),
),
);
termine = [...termine, ...neu];
} else {
const neu = await pb.collection('termine').create<Termin>(data);
termine = [...termine, neu];
const data = {
...generiereTerminDaten(new Date(fBeginn), null),
beginn: fromLocal(fBeginn),
ende: fEnde ? fromLocal(fEnde) : null,
};
if (editId) {
const updated = await pb.collection('termine').update<Termin>(editId, data);
termine = termine.map(t => t.id === editId ? updated : t);
} else {
const neu = await pb.collection('termine').create<Termin>(data);
termine = [...termine, neu];
}
}
showForm = false;
} catch (e: unknown) {
@ -106,12 +152,30 @@
}
}
async function loeschen(id: string) {
await pb.collection('termine').delete(id);
termine = termine.filter(t => t.id !== id);
async function loeschen(t: Termin, ganzeSerieLoeschen: boolean) {
if (ganzeSerieLoeschen && t.serie_id) {
const serie = termine.filter(x => x.serie_id === t.serie_id);
await Promise.all(serie.map(s => pb.collection('termine').delete(s.id)));
termine = termine.filter(x => x.serie_id !== t.serie_id);
} else {
await pb.collection('termine').delete(t.id);
termine = termine.filter(x => x.id !== t.id);
}
showDelete = null;
}
// Vorschau: wie viele Termine werden erzeugt?
const serieVorschau = $derived(() => {
if (!fWiederholung || !fBeginn || !fBis) return 0;
try {
const dtstart = new Date(fBeginn);
const until = new Date(fBis + 'T23:59:59');
const freq = fRhythmus === 'monatlich' ? RRule.MONTHLY : RRule.WEEKLY;
const interval = fRhythmus === 'zweıwoechentlich' ? 2 : 1;
return new RRule({ freq, interval, dtstart, until }).all().length;
} catch { return 0; }
});
async function setVerfuegbarkeit(t: Termin, v: Verfuegbarkeit) {
const updated = await pb.collection('termine').update<Termin>(t.id, { verfuegbarkeit: v });
termine = termine.map(x => x.id === t.id ? updated : x);
@ -183,7 +247,10 @@
</div>
<div class="karte-info">
<span class="karte-titel">{t.titel}</span>
<div class="karte-titel-zeile">
<span class="karte-titel">{t.titel}</span>
{#if t.serie_id}<span class="serie-badge" title="Wiederholungsserie"></span>{/if}
</div>
<span class="karte-meta">
{formatZeit(t.beginn)}{t.ende ? ' ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''}
</span>
@ -230,7 +297,7 @@
{#if isAdmin()}
<div class="karte-aktionen">
<button class="btn-icon" onclick={() => bearbeiten(t)} title="Bearbeiten"></button>
<button class="btn-icon btn-icon-red" onclick={() => showDelete = t.id} title="Löschen"></button>
<button class="btn-icon btn-icon-red" onclick={() => { showDelete = t.id; deleteSerieMode = false; }} title="Löschen"></button>
</div>
{/if}
</li>
@ -302,7 +369,36 @@
{/each}
</select>
</div>
{#if gruppen.length > 0}
{#if !editId}
<div class="field">
<label class="toggle-label">
<input type="checkbox" bind:checked={fWiederholung} />
Wiederholt sich
</label>
</div>
{#if fWiederholung}
<div class="row">
<div class="field">
<label for="frhythmus">Rhythmus</label>
<select id="frhythmus" bind:value={fRhythmus}>
<option value="woechentlich">Wöchentlich</option>
<option value="zweıwoechentlich">Zweiwöchentlich</option>
<option value="monatlich">Monatlich</option>
</select>
</div>
<div class="field">
<label for="fbis">Bis (Datum)</label>
<input id="fbis" type="date" bind:value={fBis} required />
</div>
</div>
{#if serieVorschau() > 0}
<p class="serie-vorschau">{serieVorschau()} Termine werden angelegt</p>
{/if}
{/if}
{/if}
{#if gruppen.length > 0}
<div class="field">
<span class="field-label">Für Gruppen</span>
<div class="checkboxes">
@ -327,13 +423,27 @@
<!-- Lösch-Dialog -->
{#if showDelete}
{@const t = termine.find(x => x.id === showDelete)!}
<div class="overlay" role="dialog" aria-modal="true">
<div class="dialog">
<p>Termin wirklich löschen?</p>
<p class="dialog-sub">Diese Aktion kann nicht rückgängig gemacht werden.</p>
<p>Termin löschen?</p>
{#if t?.serie_id}
<div class="radio-group">
<label class="radio-label">
<input type="radio" bind:group={deleteSerieMode} value={false} />
Nur diesen Termin
</label>
<label class="radio-label">
<input type="radio" bind:group={deleteSerieMode} value={true} />
Alle Termine der Serie löschen
</label>
</div>
{:else}
<p class="dialog-sub">Diese Aktion kann nicht rückgängig gemacht werden.</p>
{/if}
<div class="dialog-actions">
<button class="btn-ghost" onclick={() => showDelete = null}>Abbrechen</button>
<button class="btn-danger" onclick={() => loeschen(showDelete!)}>Löschen</button>
<button class="btn-danger" onclick={() => loeschen(t, deleteSerieMode)}>Löschen</button>
</div>
</div>
</div>
@ -368,7 +478,9 @@
.monat { font-size: 0.65rem; color: #94a3b8; text-transform: uppercase; }
.karte-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.karte-titel-zeile { display: flex; align-items: center; gap: 0.4rem; }
.karte-titel { font-weight: 600; font-size: 0.95rem; color: #1e293b; }
.serie-badge { font-size: 0.75rem; color: #1e40af; font-weight: 700; }
.karte-meta { font-size: 0.78rem; color: #64748b; }
.karte-sub { font-size: 0.72rem; color: #94a3b8; }
@ -456,7 +568,11 @@
}
.dialog p { font-size: 1rem; color: #1e293b; margin-bottom: 0.4rem; font-weight: 600; }
.dialog-sub { font-weight: 400 !important; font-size: 0.875rem; color: #94a3b8; }
.radio-group { display: flex; flex-direction: column; gap: 0.5rem; margin: 0.75rem 0; }
.radio-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: #1e293b; cursor: pointer; }
.dialog-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
.toggle-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: #475569; cursor: pointer; }
.serie-vorschau { font-size: 0.82rem; color: #1e40af; margin: -0.4rem 0 0.5rem; }
.btn-danger {
flex: 1; padding: 0.75rem; background: #dc2626; color: #fff;
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600; cursor: pointer;