Termine: Wiederholungsserien (rrule.js), iCal-Subscription-Feed (ical-generator)
This commit is contained in:
parent
c23ac90d35
commit
3ac17b2645
7 changed files with 317 additions and 38 deletions
63
app/package-lock.json
generated
63
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ export interface Termin {
|
|||
gruppe_ids: string[];
|
||||
durchfuehrender_id?: string;
|
||||
verfuegbarkeit?: Verfuegbarkeit;
|
||||
rrule?: string;
|
||||
serie_id?: string;
|
||||
}
|
||||
|
||||
export interface Nachricht {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
59
app/src/routes/api/kalender/[vereinId]/+server.ts
Normal file
59
app/src/routes/api/kalender/[vereinId]/+server.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
25
pocketbase/pb_migrations/1779230700_termine_serie.js
Normal file
25
pocketbase/pb_migrations/1779230700_termine_serie.js
Normal 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)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue