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", "name": "vereinshaus",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"ical-generator": "^10.2.0",
"pocketbase": "^0.26.9", "pocketbase": "^0.26.9",
"rrule": "^2.8.1",
"web-push": "^3.6.7" "web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
@ -3966,6 +3968,54 @@
"node": ">= 14" "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": { "node_modules/idb": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -5406,6 +5456,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -6048,9 +6107,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "0.16.0", "version": "0.16.0",

View file

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

View file

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

View file

@ -32,7 +32,7 @@
let einladungUrl = $state(''); let einladungUrl = $state('');
let einladungKopiert = $state(false); let einladungKopiert = $state(false);
let vereinId = ''; let vereinId = $state('');
const bundeslaender = [ const bundeslaender = [
['', '—'], ['', '—'],
@ -206,6 +206,18 @@
</div> </div>
</section> </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()} {#if isAdmin()}
<section> <section>
<h2>Durchführende</h2> <h2>Durchführende</h2>
@ -328,6 +340,12 @@
margin-bottom: 1.5rem; 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 { .trainer-liste {
list-style: none; padding: 0; margin: 0 0 0.75rem; list-style: none; padding: 0; margin: 0 0 0.75rem;
border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { pb } from '$lib/pb'; import { pb } from '$lib/pb';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { RRule } from 'rrule';
import type { Termin, Gruppe, Verfuegbarkeit } from '$lib/types'; import type { Termin, Gruppe, Verfuegbarkeit } from '$lib/types';
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin'; const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
@ -12,18 +13,22 @@
let loading = $state(true); let loading = $state(true);
// Formular // Formular
let showForm = $state(false); let showForm = $state(false);
let editId = $state<string | null>(null); let editId = $state<string | null>(null);
let fTitel = $state(''); let fTitel = $state('');
let fBeschr = $state(''); let fBeschr = $state('');
let fBeginn = $state(''); let fBeginn = $state('');
let fEnde = $state(''); let fEnde = $state('');
let fOrt = $state(''); let fOrt = $state('');
let fGruppeIds = $state<string[]>([]); let fGruppeIds = $state<string[]>([]);
let fDurchfuehrenderId = $state(''); let fDurchfuehrenderId = $state('');
let saving = $state(false); let fWiederholung = $state(false);
let formError = $state(''); let fRhythmus = $state<'woechentlich' | 'zweıwoechentlich' | 'monatlich'>('woechentlich');
let showDelete = $state<string | null>(null); let fBis = $state('');
let saving = $state(false);
let formError = $state('');
let showDelete = $state<string | null>(null);
let deleteSerieMode = $state(false);
const now = new Date(); const now = new Date();
@ -67,6 +72,7 @@
function neuerTermin() { function neuerTermin() {
editId = null; fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = ''; editId = null; fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = '';
fOrt = ''; fGruppeIds = []; fDurchfuehrenderId = ''; fOrt = ''; fGruppeIds = []; fDurchfuehrenderId = '';
fWiederholung = false; fRhythmus = 'woechentlich'; fBis = '';
formError = ''; showForm = true; formError = ''; showForm = true;
} }
@ -75,28 +81,68 @@
fBeginn = toLocal(t.beginn); fEnde = toLocal(t.ende); fBeginn = toLocal(t.beginn); fEnde = toLocal(t.ende);
fOrt = t.ort ?? ''; fGruppeIds = t.gruppe_ids ?? []; fOrt = t.ort ?? ''; fGruppeIds = t.gruppe_ids ?? [];
fDurchfuehrenderId = t.durchfuehrender_id ?? ''; fDurchfuehrenderId = t.durchfuehrender_id ?? '';
fWiederholung = false; // Einzeltermin bearbeiten, nie die ganze Serie
formError = ''; showForm = true; 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() { async function speichern() {
if (!fTitel.trim() || !fBeginn) { formError = 'Titel und Beginn sind Pflichtfelder.'; return; } 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; formError = ''; saving = true;
try { try {
const vid = pb.authStore.record?.verein_id as string; if (fWiederholung && !editId) {
const data = { // Serie anlegen: rrule generieren, Einzeltermine erstellen
verein_id: vid, titel: fTitel.trim(), const dtstart = new Date(fBeginn);
beschreibung: fBeschr.trim() || null, const until = new Date(fBis + 'T23:59:59');
beginn: fromLocal(fBeginn), ende: fEnde ? fromLocal(fEnde) : null, const dauer = fEnde ? new Date(fEnde).getTime() - dtstart.getTime() : 60 * 60 * 1000;
ort: fOrt.trim() || null, gruppe_ids: fGruppeIds,
durchfuehrender_id: fDurchfuehrenderId || null, const freq = fRhythmus === 'monatlich' ? RRule.MONTHLY : RRule.WEEKLY;
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null, const interval = fRhythmus === 'zweıwoechentlich' ? 2 : 1;
};
if (editId) { const rule = new RRule({ freq, interval, dtstart, until });
const updated = await pb.collection('termine').update<Termin>(editId, data); const rruleStr = `FREQ=${fRhythmus === 'monatlich' ? 'MONTHLY' : 'WEEKLY'}${interval === 2 ? ';INTERVAL=2' : ''};UNTIL=${until.toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`;
termine = termine.map(t => t.id === editId ? updated : t); 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 { } else {
const neu = await pb.collection('termine').create<Termin>(data); const data = {
termine = [...termine, neu]; ...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; showForm = false;
} catch (e: unknown) { } catch (e: unknown) {
@ -106,12 +152,30 @@
} }
} }
async function loeschen(id: string) { async function loeschen(t: Termin, ganzeSerieLoeschen: boolean) {
await pb.collection('termine').delete(id); if (ganzeSerieLoeschen && t.serie_id) {
termine = termine.filter(t => t.id !== 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; 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) { async function setVerfuegbarkeit(t: Termin, v: Verfuegbarkeit) {
const updated = await pb.collection('termine').update<Termin>(t.id, { verfuegbarkeit: v }); const updated = await pb.collection('termine').update<Termin>(t.id, { verfuegbarkeit: v });
termine = termine.map(x => x.id === t.id ? updated : x); termine = termine.map(x => x.id === t.id ? updated : x);
@ -183,7 +247,10 @@
</div> </div>
<div class="karte-info"> <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"> <span class="karte-meta">
{formatZeit(t.beginn)}{t.ende ? ' ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''} {formatZeit(t.beginn)}{t.ende ? ' ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''}
</span> </span>
@ -230,7 +297,7 @@
{#if isAdmin()} {#if isAdmin()}
<div class="karte-aktionen"> <div class="karte-aktionen">
<button class="btn-icon" onclick={() => bearbeiten(t)} title="Bearbeiten"></button> <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> </div>
{/if} {/if}
</li> </li>
@ -302,7 +369,36 @@
{/each} {/each}
</select> </select>
</div> </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"> <div class="field">
<span class="field-label">Für Gruppen</span> <span class="field-label">Für Gruppen</span>
<div class="checkboxes"> <div class="checkboxes">
@ -327,13 +423,27 @@
<!-- Lösch-Dialog --> <!-- Lösch-Dialog -->
{#if showDelete} {#if showDelete}
{@const t = termine.find(x => x.id === showDelete)!}
<div class="overlay" role="dialog" aria-modal="true"> <div class="overlay" role="dialog" aria-modal="true">
<div class="dialog"> <div class="dialog">
<p>Termin wirklich löschen?</p> <p>Termin löschen?</p>
<p class="dialog-sub">Diese Aktion kann nicht rückgängig gemacht werden.</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"> <div class="dialog-actions">
<button class="btn-ghost" onclick={() => showDelete = null}>Abbrechen</button> <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> </div>
</div> </div>
@ -368,7 +478,9 @@
.monat { font-size: 0.65rem; color: #94a3b8; text-transform: uppercase; } .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-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; } .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-meta { font-size: 0.78rem; color: #64748b; }
.karte-sub { font-size: 0.72rem; color: #94a3b8; } .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 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; } .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; } .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 { .btn-danger {
flex: 1; padding: 0.75rem; background: #dc2626; color: #fff; flex: 1; padding: 0.75rem; background: #dc2626; color: #fff;
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600; cursor: pointer; 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)
})