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

63
app/package-lock.json generated
View file

@ -8,7 +8,9 @@
"name": "vereinshaus",
"version": "0.1.0",
"dependencies": {
"ical-generator": "^10.2.0",
"pocketbase": "^0.26.9",
"rrule": "^2.8.1",
"web-push": "^3.6.7"
},
"devDependencies": {
@ -3966,6 +3968,54 @@
"node": ">= 14"
}
},
"node_modules/ical-generator": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-10.2.0.tgz",
"integrity": "sha512-XR5FsiDWCsz5MwBwMA/sQqR3A9H240xkXIeXOabV7uNAiieP+TA9rleVvlwPLRXMz+CXME8cGuDd7cdnE5At6w==",
"license": "MIT",
"engines": {
"node": "20 || 22 || >=24"
},
"peerDependencies": {
"@touch4it/ical-timezones": ">=1.6.0",
"@types/luxon": ">= 1.26.0",
"@types/mocha": ">= 8.2.1",
"dayjs": ">= 1.10.0",
"luxon": ">= 1.26.0",
"moment": ">= 2.29.0",
"moment-timezone": ">= 0.5.33",
"rrule": ">= 2.6.8"
},
"peerDependenciesMeta": {
"@touch4it/ical-timezones": {
"optional": true
},
"@types/luxon": {
"optional": true
},
"@types/mocha": {
"optional": true
},
"@types/node": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-timezone": {
"optional": true
},
"rrule": {
"optional": true
}
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -5406,6 +5456,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/rrule": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -6048,9 +6107,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "0.16.0",

View file

@ -25,7 +25,9 @@
"workbox-precaching": "^7.4.1"
},
"dependencies": {
"ical-generator": "^10.2.0",
"pocketbase": "^0.26.9",
"rrule": "^2.8.1",
"web-push": "^3.6.7"
}
}

View file

@ -90,6 +90,8 @@ export interface Termin {
gruppe_ids: string[];
durchfuehrender_id?: string;
verfuegbarkeit?: Verfuegbarkeit;
rrule?: string;
serie_id?: string;
}
export interface Nachricht {

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';
@ -21,9 +22,13 @@
let fOrt = $state('');
let fGruppeIds = $state<string[]>([]);
let fDurchfuehrenderId = $state('');
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,21 +81,60 @@
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;
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 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,
...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);
@ -98,6 +143,7 @@
const neu = await pb.collection('termine').create<Termin>(data);
termine = [...termine, neu];
}
}
showForm = false;
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Fehler beim Speichern.';
@ -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">
<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,6 +369,35 @@
{/each}
</select>
</div>
{#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>
@ -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>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;

View file

@ -0,0 +1,59 @@
import ical from 'ical-generator';
import { env } from '$env/dynamic/private';
const PB_URL = () => env.PB_URL ?? 'http://localhost:8090';
export async function GET({ params }) {
const { vereinId } = params;
// Verein laden (öffentlich lesbar via viewRule)
const vereinRes = await fetch(
`${PB_URL()}/api/collections/vereine/records/${vereinId}`,
);
if (!vereinRes.ok) {
return new Response('Verein nicht gefunden.', { status: 404 });
}
const verein = await vereinRes.json();
// Termine der nächsten 365 Tage laden
const von = new Date().toISOString();
const bis = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
const filter = encodeURIComponent(`verein_id = "${vereinId}" && beginn >= "${von}" && beginn <= "${bis}"`);
const termineRes = await fetch(
`${PB_URL()}/api/collections/termine/records?filter=${filter}&sort=beginn&perPage=500`,
);
const { items: termine = [] } = termineRes.ok ? await termineRes.json() : {};
// iCal-Kalender aufbauen
const cal = ical({
name: verein.name ?? 'vereins.haus',
prodId: '//vereins.haus//Vereinskalender//DE',
timezone: 'Europe/Berlin',
ttl: 60 * 60, // Clients aktualisieren stündlich
});
for (const t of termine) {
const start = new Date(t.beginn);
const end = t.ende
? new Date(t.ende)
: new Date(start.getTime() + 60 * 60 * 1000); // Default: 1 Stunde
const ev = cal.createEvent({
start,
end,
summary: t.titel,
location: t.ort ?? undefined,
description: t.beschreibung ?? undefined,
});
ev.uid(t.id + '@vereins.haus');
}
return new Response(cal.toString(), {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(verein.name ?? 'kalender')}.ics"`,
'Cache-Control': 'no-cache',
},
});
}

View file

@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const c = app.findCollectionByNameOrId("pbc_2279568741") // termine
// rrule RFC-5545-Wiederholungsregel (nur RRULE-Teil ohne DTSTART)
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000090", "name": "rrule",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
// serie_id gruppiert alle Termine einer Wiederholungsserie
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000091", "name": "serie_id",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId("pbc_2279568741")
c.fields.removeById("text2001000090")
c.fields.removeById("text2001000091")
app.save(c)
})