📆 FullCalendar
Alle Angaben und Versionen entsprechen dem Stand zum Zeitpunkt der Erstellung.
Das Kalendersystem basiert auf FullCalendar 6 und MODX 3 und fasst mehrere Datenquellen in einer gemeinsamen, übersichtlichen Kalenderoberfläche zusammen. Neben lokal gepflegten Terminen über eigene TVs lassen sich zusätzliche Ereignisse über externe oder interne ICS-Dateien sowie eigene JSON-Quellen integrieren.
Alle Quellen werden unabhängig voneinander geladen. Fällt eine externe ICS-Datei aus oder enthält fehlerhafte Einträge, wird sie ignoriert, ohne die übrige Kalenderfunktion zu beeinträchtigen. Das System unterstützt farbliche Kategorien, Kalenderwochen, mobile Bedienung per Wischgesten, Tooltip-Hinweise und optionale Verlinkungen für weiterführende Informationen.
Diese Kombination ermöglicht eine flexible und robuste Kalenderlösung, die sowohl interne Inhalte als auch externe Daten zuverlässig zusammenführt.
☀️ Optional verwendbar mit dwdWetter.php
📅 FullCalendar 6.1.19 in MODX 3.1.2
mit Bootstrap 5, Spacelab, JSON-Terminen, ICS-Import, Zusatz-Events, Ferien/Feiertagen und Tooltip
Links FullCalendar:
🗂️ 1. Ordnerstruktur (lokale FullCalendar-Dateien)
assets/components/fullcalendar/
│
├── index.global.min.js
├── de.global.min.js
│
└── bootstrap5/
└── index.global.min.js
→ ICS-Parser wird nicht mehr benötigt (wir wandeln ICS selbst per Snippet in JSON um).
🎨 2. Chunk: fcCalendar
Dieser Chunk wird in Seiten eingebunden, auf denen der Kalender erscheinen soll.
[[!fcRegisterAssets]]
<div class="container my-4">
<h1 class="h3 mb-3">
<i class="bi bi-calendar3"></i> Kalender
</h1>
<div id="calendar"></div>
</div>
Beispiel für eine Ressource (Kalender + Legende)
[[$fcCalendar]]
<div class="mt-4 ms-3">
<p class="mb-1"><em>Farblegende:</em></p>
<p class="small">
<span class="badge bg-primary me-1"> </span>
Termine mit Artikel/Info-Link<br>
<span class="badge bg-success me-1"> </span>
Abfuhrtermine A.R.T.<br>
<small class="ms-4 d-block">
Quelle:
<a href="https://www.art-trier.de/abfuhrtermin" target="_blank" rel="noopener noreferrer">
www.art-trier.de/abfuhrtermin
</a>
</small>
<span class="badge me-1" style="background-color: #7e2555;"> </span>
Schulferien Rheinland-Pfalz<br>
<span class="badge me-1" style="background-color: #7e6025;"> </span>
Feiertage und besondere kirchliche Tage<br>
<span class="badge me-1" style="background-color: #6b7d3f;"> </span>
Brauchtum & Tradition<br>
<span class="badge me-1" style="background-color: #138496;"> </span>
Zeitumstellung Sommer-/Winterzeit
</p>
<p class="small text-muted mb-0">
<em>Alle Angaben ohne Gewähr. Keine Garantie für Korrektheit der Daten.</em>
</p>
</div>
🧩 3. Snippet: fcRegisterAssets
(Registriert die FullCalendar-Skripte und initialisiert alle JSON-Quellen)
<?php
/** @var modX $modx */
$assetsUrl = MODX_ASSETS_URL . 'components/fullcalendar/';
// FullCalendar Core
$modx->regClientScript($assetsUrl . 'index.global.min.js');
// Deutsche Sprache
$modx->regClientScript($assetsUrl . 'de.global.min.js');
// Bootstrap 5 Plugin
$modx->regClientScript($assetsUrl . 'bootstrap5/index.global.min.js');
$initJs = <<<JS
document.addEventListener('DOMContentLoaded', function () {
var calendarEl = document.getElementById('calendar');
if (!calendarEl) return;
// Hilfsfunktion: Text auf maxLen kürzen, letztes Wort bleibt ausgeschrieben
function shortenKeepLast(str, maxLen) {
if (!str) return '';
if (str.length <= maxLen) return str;
var words = str.split(' ');
if (words.length === 1) {
// Nur ein langes Wort: stumpf abschneiden
return str.slice(0, maxLen);
}
var lastWord = words[words.length - 1];
// Wenn das letzte Wort fast alles frisst, nutze nur dieses
if (lastWord.length >= maxLen) {
return lastWord.slice(0, maxLen);
}
var remaining = maxLen - lastWord.length - 1; // Platz für Prefix + Leerzeichen
var prefix = '';
for (var i = 0; i < words.length - 1; i++) {
var w = words[i];
if (!w) continue;
var candidate = prefix ? prefix + ' ' + w : w;
if (candidate.length > remaining) break;
prefix = candidate;
}
if (prefix) {
return prefix + ' ' + lastWord;
}
// Fallback: nur letztes Wort
return lastWord;
}
var savedDate = null;
try {
savedDate = localStorage.getItem('fcLastDate');
} catch (e) {}
var calendar = new FullCalendar.Calendar(calendarEl, {
locale: 'de',
themeSystem: 'bootstrap5',
initialView: 'dayGridMonth',
initialDate: savedDate || undefined,
weekNumbers: true,
weekNumberCalculation: 'ISO',
displayEventTime: false,
eventMaxStack: 2,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
eventSources: [
{ url: '/json/kalender-events.json', color: '#0d6efd', textColor: '#ffffff' },
{ url: '/json/ics-muell-events.json', color: '#198754', textColor: '#ffffff' },
{ url: '/json/kalender-feiertage.json', textColor: '#ffffff' },
{ url: '/json/kalender-sommerwinter.json', textColor: '#ffffff' },
{ url: '/json/kalender-adddays.json', textColor: '#ffffff' },
{
// Wetter-Events – OHNE color, damit kein blauer Block
url: '/json/kalender-wetter.json',
textColor: '#000000'
}
],
// zusätzliche Klasse für Wetter-Events → CSS kann sie gezielt stylen
eventClassNames: function(arg) {
var ext = arg.event.extendedProps || {};
if (ext.type === 'weather') {
return ['fc-event-weather'];
}
return [];
},
// Wetter-Icons + Temp, andere Events normal mit Text
eventContent: function(arg) {
var ext = arg.event.extendedProps || {};
// Wetter-Event
if (ext.type === 'weather') {
var wrap = document.createElement('div');
wrap.className = 'fc-weather-event';
if (ext.icon) {
var img = document.createElement('img');
img.className = 'fc-weather-icon';
img.src = ext.icon;
img.alt = arg.event.title || '';
wrap.appendChild(img);
}
if (ext.temp) {
var span = document.createElement('span');
span.className = 'fc-weather-temp';
span.textContent = ext.temp;
wrap.appendChild(span);
}
return { domNodes: [wrap] };
}
// Normale Events (Müll, Feiertage, AddDays …)
var container = document.createElement('div');
var titleEl = document.createElement('div');
titleEl.className = 'fc-event-title';
// Voller Titel
var fullTitle = arg.event.title || '';
// Nur den ersten Block vor dem ersten " - "
var firstPart = fullTitle.split(' - ')[0];
// Optional kürzen (max. 25 Zeichen)
var MAX_LEN = 25;
var shortTitle = firstPart.length > MAX_LEN
? firstPart.slice(0, MAX_LEN) + '…'
: firstPart;
// Anzeigen
titleEl.textContent = shortTitle;
container.appendChild(titleEl);
return { domNodes: [container] };
},
// Tooltip
eventDidMount: function(info) {
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
new bootstrap.Tooltip(info.el, {
title: info.event.title,
placement: 'top',
container: 'body'
});
} else {
info.el.setAttribute('title', info.event.title);
}
},
// Klick auf Event: URL öffnen (funktioniert auch für Wetter, wenn wir url setzen)
eventClick: function(info) {
if (info.event.url) {
window.open(info.event.url, '_self');
info.jsEvent.preventDefault();
return;
}
var ext = info.event.extendedProps || {};
if (ext.url) {
window.open(ext.url, '_self');
info.jsEvent.preventDefault();
}
},
datesSet: function(info) {
try {
var current = calendar.getDate();
var safe = new Date(current.getFullYear(), current.getMonth(), 15);
localStorage.setItem('fcLastDate', safe.toISOString());
} catch (e) {}
}
});
calendar.render();
// =========================
// Tastatur-Navigation ← / →
// =========================
document.addEventListener('keydown', function (e) {
var target = e.target;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
if (e.key === 'ArrowLeft') {
calendar.prev();
} else if (e.key === 'ArrowRight') {
calendar.next();
}
});
// =========================
// Swipe-Navigation auf Touch-Geräten
// =========================
var touchStartX = null;
calendarEl.addEventListener('touchstart', function (e) {
if (!e.changedTouches || !e.changedTouches.length) return;
touchStartX = e.changedTouches[0].screenX;
});
calendarEl.addEventListener('touchend', function (e) {
if (touchStartX === null || !e.changedTouches || !e.changedTouches.length) return;
var dx = e.changedTouches[0].screenX - touchStartX;
var threshold = 50; // mindestens 50px Bewegung
if (Math.abs(dx) > threshold) {
if (dx < 0) {
// nach links wischen → nächster Monat
calendar.next();
} else {
// nach rechts wischen → vorheriger Monat
calendar.prev();
}
}
touchStartX = null;
});
});
JS;
$modx->regClientHTMLBlock('<script>' . $initJs . '</script>');
return '';
Dieses Snippet enthält:
- FullCalendar-Initialisierung
- Tooltip per Bootstrap
- Touch-Gesten
- Blättern mit den Tasten Links/Rechts
- Aktuelles Datum der Ansicht wird gespeichert
- Event-Click-Handling (Links öffnen)
- alle JSON-Quellen:
/kalender-events.json(normale Termine)/ics-muell-events.json(Müllkalender)/kalender-feiertage.json(Feiertage)/kalender-sommerwinter.json(Sommer-/Winterzeit)/kalender-adddays.json(zusätzliche Tage)/kalender-wetter.json(Wetter, textColor z.B. schwarz, ohnecolor, damit kein farbiger Hintergrund)
Wer
/kalender-wetter.jsonnicht nutzen möchte, kann die Datei einfach entfernen oder auskommentieren.
eventContent (Darstellung der Events):
- Für
type === 'weather':- DOM-Struktur z.B.:
<div class="fc-weather-event"><img class="fc-weather-icon" src="[icon]" alt="[title]"><span class="fc-weather-temp">[temp]</span>
</div>
iconundtempkommen ausextendedProps
- DOM-Struktur z.B.:
- Für alle anderen Events:
- Standard-Event-Titel anzeigen (z.B.
<div class="fc-event-title">[title]</div>)
- Standard-Event-Titel anzeigen (z.B.
eventDidMount (Tooltip):
- wenn
bootstrap.Tooltipverfügbar:- Tooltip mit
title = info.event.title - der Text kommt aus
dwdWetterConditionDe(viafcWetterJson)
- Tooltip mit
- sonst:
title-Attribut setzen
eventClick:
- wenn
event.urlgesetzt → in_selföffnen- z.B. die Wetter-Detailseite (Resource-ID
42)
- z.B. die Wetter-Detailseite (Resource-ID
datesSet:
- aktuelles View-Datum (Monatsmitte) im
localStorageunterfcLastDatespeichern - beim nächsten Laden des Kalenders dieses Datum wieder als Start verwenden
🏗️ 4. Template: Kalender-JSON-Ausgabe
Stark minimiertes Template:
[[*content]]
Typ: JSON
Wird verwendet für:
- /kalender-events.json
- /ics-muell-events.json
- /kalender-feiertage.json
- /kalender-sommerwinter.json
- /kalender-adddays.json
- /kalender-wetter.json
📄 5. JSON-Ressourcen anlegen
❗ Jede JSON-Quelle ist eine eigene Ressource:
Beispiel-Übersicht:
| Ressource | Alias | Inhalt |
|---|---|---|
| Kalender TV-Termine | kalender-events | [[!eventsJson? &parents=33,55,123]] |
| Müllkalender | ics-muell-events | [[!ics2calendarJson? &icsFile=assets/.../ortsname.ics &color=#198754]] |
| Feiertage | kalender-feiertage | [[!fcFeiertageJson]] |
| Sommer/Winterzeit | kalender-sommerwinter | [[!fcSommerWinterJson]] |
| AddDays (Berichte/Ereignisse) | kalender-adddays | [[!fcAddDaysJson]] |
| Kalender Wetter | kalender-wetter | Details siehe: [[!fcWetterJson? &...]] |
[[!fcWetterJson?
&stationID=`K428`
&jsonDir=`[[++base_path]]assets/dwdWetter/json/`
&tempField=`tempMean`
&iconBaseUrl=`/assets/dwdWetter/dwd_img/`
&weatherResourceId=`42`
&wwField=`mainSigWeatherCode`
]]
stationID→ Station wie im Wetter-Hauptscript (z.B.K428)jsonDir→ Dateisystempfad zum JSON-OrdnertempField→ Feldname für Tages-Temperatur imdaily-ArrayiconBaseUrl→ URL-Basis der Wetter-Icons (PNG, transparent)weatherResourceId→ ID der Detailseite, die bei Klick geöffnet werden sollwwField→ Feldname für den DWD-ww-Code (hiermainSigWeatherCode
Alle JSON-Ressourcen:
- veröffentlicht
- Template: JSON-Output
- Format: JSON
- Inhalt: Snippet-Aufruf
📅 6. TVs anlegen (Start/Enddatum)
TV: tvStartdatum
- Bezeichnung:
Startdatum für den Kalender - Beschreibung:
Das Datum muss nach ISO 8601 angegeben werden, entweder als „yyyy-mm-dd“ oder mit Uhrzeit als „yyyy-mm-dd hh:mm“, z.B. 2025-11-26 19:00; mehrere Termine kannst du mit „||“ hintereinander eintragen. - Eingabetyp:
Text - Leere Eingabe erlauben:
Ja - Minimale Länge:
10 - Ausgabeformat:
Text
TV: tvEnddatum
- Bezeichnung:
Enddatum für den Kalender - rest wie wie oben
Beide TVs zuweisen: ➡️ nur für Template tmpMODX
Optional: TV-Validierung für Kalender-Datumsfelder
Unterstützte Formate
- Einzelnes Datum:
YYYY-MM-DD - Datum + Uhrzeit:
YYYY-MM-DD HH:MModerYYYY-MM-DD HH:MM:SS - Mehrere Datumswerte mit
||(Leerzeichen erlaubt)
1) Streng – kein abschließendes || (empfohlen)
^\s*\d{4}-\d{2}-\d{2}(?:\s+[0-2]\d:[0-5]\d(?:\:[0-5]\d)?)?(?:\s*\|\|\s*\d{4}-\d{2}-\d{2}(?:\s+[0-2]\d:[0-5]\d(?:\:[0-5]\d)?)?)*\s*$
2) Locker – erlaubt abschließendes ||
^\s*(?:\d{4}-\d{2}-\d{2}(?:\s+[0-2]\d:[0-5]\d(?:\:[0-5]\d)?)?)(?:\s*\|\|\s*\d{4}-\d{2}-\d{2}(?:\s+[0-2]\d:[0-5]\d(?:\:[0-5]\d)?)?)*\s*(?:\|\|\s*)?$
(Ein abschließendes || ist unkritisch – der Code ignoriert leere Elemente.)
Beispiele, die erlaubt sind
2025-11-282025-11-28 10:002025-11-28 10:00:002025-11-28 || 2025-11-302025-11-28 10:00 || 2025-11-30 22:00:002025-11-28 || 2025-11-30 ||(nur bei Variante 2)
🧩 7. Snippet: eventsJson
(TV-basierte Events → JSON)
<?php
# Snippet: eventsJson
# V 2025-11-26 + DEBUG-Schalter + Separator
/** @var modX $modx */
# Debug an/aus: [[!eventsJson? &debug=`1`]]
$debug = (int)$modx->getOption('debug', $scriptProperties, 0);
# Konfigurierbarer Trenner für Mehrfachwerte
# Aufruf z.B. [[!eventsJson? &separator=`||`]]
$sep = $modx->getOption('separator', $scriptProperties, '||');
# Mehrere Parents erlauben: [[!eventsJson? &parents=`33,55`]]
$parentsRaw = $modx->getOption('parents', $scriptProperties, '');
$parents = array_filter(array_map('trim', explode(',', $parentsRaw)));
# FullCalendar übergibt ?start=...&end=... (ISO-Strings)
$startParam = isset($_GET['start']) ? strtotime($_GET['start']) : null;
$endParam = isset($_GET['end']) ? strtotime($_GET['end']) : null;
# strtotime kann false liefern → sauber auf null setzen
if ($startParam === false) {
$startParam = null;
}
if ($endParam === false) {
$endParam = null;
}
# Eigene Variable: nur filtern, wenn beide Parameter vorhanden sind
$useRangeFilter = ($startParam !== null && $endParam !== null);
# DEBUG: Range-Parameter loggen
if ($debug) {
$modx->log(modX::LOG_LEVEL_ERROR,
"eventsJson DEBUG: Range startParam=" . var_export($startParam, true) .
" endParam=" . var_export($endParam, true) .
" useRangeFilter=" . ($useRangeFilter ? 'true' : 'false')
);
}
# Basis-Query für Ressourcen
$c = $modx->newQuery('modResource');
$c->where([
'published' => 1,
'deleted' => 0,
]);
# Nur bestimmte Elternordner berücksichtigen (falls über &parents gesetzt)
if (!empty($parents)) {
$c->where(['parent:IN' => $parents]);
}
$resources = $modx->getCollection('modResource', $c);
$events = [];
# Schwellwert, um alte/Default-Daten wie 1970-01-01 zu ignorieren
$minValidTs = strtotime('1971-01-01');
foreach ($resources as $res) {
# TVs für Start/Ende (Raw-Strings, können mehrere Werte enthalten)
$startTvRaw = trim((string)$res->getTVValue('tvStartdatum'));
$endTvRaw = trim((string)$res->getTVValue('tvEnddatum'));
# DEBUG
if ($debug) {
$modx->log(modX::LOG_LEVEL_ERROR,
"eventsJson DEBUG: Resource " . $res->get('id') .
" RAW startTvRaw=\"" . $startTvRaw . "\" endTvRaw=\"" . $endTvRaw . "\""
);
}
# Kein Startdatum oder leer -> Event (diese Ressource) komplett überspringen
if ($startTvRaw === '' || $startTvRaw === '0') {
continue;
}
# Mehrere Start-/Enddaten unterstützen: Trennung per konfiguriertem Separator
$startParts = array_map('trim', explode($sep, $startTvRaw));
$endParts = $endTvRaw !== '' ? array_map('trim', explode($sep, $endTvRaw)) : [];
# DEBUG
if ($debug) {
$modx->log(modX::LOG_LEVEL_ERROR,
"eventsJson DEBUG: Resource " . $res->get('id') .
" startParts=" . print_r($startParts, true) .
" endParts=" . print_r($endParts, true)
);
}
# Für jeden Start-Eintrag ein eigenes Event erzeugen
foreach ($startParts as $idx => $startStr) {
if ($startStr === '' || $startStr === '0') {
continue;
}
$startTs = strtotime($startStr);
# DEBUG
if ($debug) {
$modx->log(modX::LOG_LEVEL_ERROR,
"eventsJson DEBUG: Resource " . $res->get('id') .
" checking startStr=" . $startStr . " startTs=" . var_export($startTs, true)
);
}
if ($startTs === false || $startTs < $minValidTs) {
# ungültig oder „1970…“ etc.
continue;
}
# Passendes Enddatum (falls vorhanden) anhand des Index
$endStr = '';
$endTs = null;
if (isset($endParts[$idx])) {
$endStr = $endParts[$idx];
if ($endStr !== '' && $endStr !== '0') {
$endTs = strtotime($endStr);
# DEBUG
if ($debug) {
$modx->log(modX::LOG_LEVEL_ERROR,
"eventsJson DEBUG: Resource " . $res->get('id') .
" checking endStr=" . $endStr . " endTs=" . var_export($endTs, true)
);
}
if ($endTs !== false && $endTs < $minValidTs) {
# 1970-Enddatum ignorieren
$endStr = '';
$endTs = null;
}
} else {
$endStr = '';
$endTs = null;
}
}
# Optional: grobe Filterung anhand der von FullCalendar gewünschten Range
if ($useRangeFilter) {
$eventEndTs = $endTs ?: $startTs;
# DEBUG
if ($debug) {
$modx->log(modX::LOG_LEVEL_ERROR,
"eventsJson DEBUG: Resource " . $res->get('id') .
" RangeCheck startTs=" . $startTs .
" eventEndTs=" . $eventEndTs .
" vs startParam=" . $startParam .
" endParam=" . $endParam
);
}
if ($eventEndTs < $startParam || $startTs > $endParam) {
# Diese einzelne Instanz liegt komplett außerhalb der Range
continue;
}
}
$events[] = [
'id' => $res->get('id'), # ID der Ressource bleibt unverändert
'title' => $res->get('pagetitle'),
# TVs im ISO-Format speichern: YYYY-MM-DD oder YYYY-MM-DD HH:MM
'start' => $startStr,
'end' => $endStr !== '' ? $endStr : null,
'url' => $modx->makeUrl($res->get('id'), '', '', 'full'),
];
# DEBUG
if ($debug) {
$modx->log(modX::LOG_LEVEL_ERROR,
"eventsJson DEBUG: Resource " . $res->get('id') .
" EVENT added start=" . $startStr . " end=" . $endStr
);
}
}
}
return json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Kalender eventsJson – Kurzüberblick
Eingabe in den TVs
tvStartdatumundtvEnddatumkönnen mehrere Werte enthalten, getrennt durch||.- Beispiel:
2025-06-02 || 2025-07-14 || 2025-08-02 10:00
2025-06-02 || 2025-07-16 || 2025-08-03 12:00
Verhalten
- Jeder Eintrag in
tvStartdatumerzeugt ein eigenes Event. tvEnddatumwird indexbasiert zugeordnet (Start 1 → Ende 1).- Kein Enddatum → FullCalendar zeigt einen Ein-Tages-Termin.
- Ungültige oder „1970…“-Daten werden ignoriert.
- Range-Filter (
?start/?end) wirkt pro Event einzeln. - Ergebnis: Genau die Termine, die gültig und innerhalb der Range liegen, werden angezeigt.
🗑️ 8. Snippet: ics2calendarJson
(importiert und cached ICS → JSON)
<?php
/**
* ics2calendarJson
* V 2025-11-26 + Cache for remote ICS + Debug
*
* Liest eine ICS-Datei vom Server (lokal oder per URL) und gibt ein JSON-Array
* mit FullCalendar-Events zurück.
*
* Aufruf-Beispiele:
* - lokal:
* [[!ics2calendarJson? &icsFile=`assets/modx/content/kalender_ics/xxxxx_xxxxxxxxxxxx.ics` &color=`#198754`]]
*
* - extern:
* [[!ics2calendarJson? &icsFile=`https://art-trier.de/xxx-feed/xxxxx:xxxxxxxxxxxx.ics` &color=`#198754`]]
*
* - mit Debug-Log (sichtbar im MODX-Manager, Level ERROR):
* [[!ics2calendarJson? &icsFile=`https://...` &color=`#198754` &debug=`1`]]
*/
/** @var modX $modx */
$defaultColor = '#3A87AD';
// Cache-Dauer für REMOTE-ICS (http/https) in Tagen
$remoteCacheDays = 7;
// Parameter
$icsFile = $modx->getOption('icsFile', $scriptProperties, '');
$color = $modx->getOption('color', $scriptProperties, $defaultColor);
$debug = (int)$modx->getOption('debug', $scriptProperties, 0);
// Wenn kein Pfad übergeben → leeres JSON
if (empty($icsFile) || $icsFile === '[[+icsFile]]') {
return json_encode([], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
// prüfen, ob es eine externe URL ist
$isRemote = (bool) preg_match('#^https?://#i', $icsFile);
$lines = [];
// Cache-Variablen (nur für Remote wirklich benutzt)
$cacheLifetime = max(0, (int)$remoteCacheDays) * 86400; // Sekunden
$cacheKey = '';
$cacheOptions = [
'cache_key' => 'ics2calendarJson', // eigener Cache-Namespace
];
// REMOTE-URL (https://…)
if ($isRemote) {
if ($cacheLifetime > 0 && $modx->getCacheManager()) {
// Cache-Key auf Basis von icsFile + color
$cacheKey = 'ics2calendarJson_' . md5($icsFile . '|' . $color);
$cached = $modx->cacheManager->get($cacheKey, $cacheOptions);
if ($cached !== null) {
if ($debug) {
$modx->log(
modX::LOG_LEVEL_ERROR,
'[ics2calendarJson] Loaded from cache: ' . $icsFile
);
}
return $cached;
}
}
$content = @file_get_contents($icsFile);
if ($content === false) {
$modx->log(
modX::LOG_LEVEL_ERROR,
'[ics2calendarJson] Could not fetch remote ICS URL: ' . $icsFile
);
return json_encode([], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if ($debug) {
$modx->log(
modX::LOG_LEVEL_ERROR,
'[ics2calendarJson] Loaded remote ICS fresh: ' . $icsFile
);
}
// Inhalt in Zeilen zerlegen
$lines = preg_split('/\r\n|\r|\n/', $content);
} else {
// LOKALE DATEI wie bisher
// Wenn der Pfad mit "/" beginnt, diesen entfernen (relativ ab MODX_BASE_PATH)
if (str_starts_with($icsFile, '/')) {
$icsFile = ltrim($icsFile, '/');
}
$fullPath = MODX_BASE_PATH . $icsFile;
if (!file_exists($fullPath)) {
$modx->log(
modX::LOG_LEVEL_ERROR,
'[ics2calendarJson] ICS file does not exist: ' . $fullPath
);
return json_encode([], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$lines = file($fullPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
}
// jetzt ist $lines gefüllt – egal ob lokal oder remote
if (empty($lines) || trim($lines[0]) !== 'BEGIN:VCALENDAR') {
$modx->log(
modX::LOG_LEVEL_ERROR,
'[ics2calendarJson] File is not an iCal-File or is empty: ' . $icsFile
);
return json_encode([], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
// Suchstrings wie in deinem alten Snippet
$strSearchSTART = 'DTSTART;TZID=Europe/Berlin:';
$strSearchEND = 'DTEND;TZID=Europe/Berlin:';
$strSearchSUM = 'SUMMARY:';
$strSearchDESC = 'DESCRIPTION:';
$strSearchEndEvent = 'END:VEVENT';
$events = [];
$current = [
'start' => null,
'end' => null,
'title' => '',
'description' => '',
];
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, $strSearchSTART)) {
$strTemp = substr($line, strlen($strSearchSTART));
$ts = strtotime($strTemp);
if ($ts) {
$current['start'] = date('Y-m-d', $ts);
} else {
$modx->log(
modX::LOG_LEVEL_ERROR,
'[ics2calendarJson] Invalid DTSTART format: ' . $line
);
}
continue;
}
if (str_starts_with($line, $strSearchEND)) {
$strTemp = substr($line, strlen($strSearchEND));
$ts = strtotime($strTemp);
if ($ts) {
$current['end'] = date('Y-m-d', $ts);
} else {
$modx->log(
modX::LOG_LEVEL_ERROR,
'[ics2calendarJson] Invalid DTEND format: ' . $line
);
}
continue;
}
if (str_starts_with($line, $strSearchSUM)) {
$strTemp = substr($line, strlen($strSearchSUM));
$strTemp = htmlspecialchars(trim($strTemp));
$strTemp = str_replace('&', '&', $strTemp);
$current['title'] = $strTemp;
continue;
}
if (str_starts_with($line, $strSearchDESC)) {
$strTemp = substr($line, strlen($strSearchDESC));
$strTemp = htmlspecialchars(trim($strTemp));
$strTemp = str_replace('&', '&', $strTemp);
$current['description'] = $strTemp;
continue;
}
if (str_starts_with($line, $strSearchEndEvent)) {
// Ein Event ist komplett – wenn start gesetzt ist, übernehmen
if (!empty($current['start'])) {
$event = [
'start' => $current['start'],
'end' => $current['end'] ?: null,
'title' => $current['title'] ?: '(ohne Titel)',
'color' => $color,
];
// Optional: description in extendedProps
if (!empty($current['description'])) {
$event['extendedProps'] = [
'description' => $current['description'],
];
}
$events[] = $event;
}
// Event-Daten zurücksetzen
$current = [
'start' => null,
'end' => null,
'title' => '',
'description' => '',
];
}
}
// JSON erzeugen
$json = json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// Erfolgreiches Ergebnis in den Cache schreiben (NUR für Remote, wenn Cache > 0)
if ($isRemote && $cacheLifetime > 0 && $cacheKey !== '' && $modx->getCacheManager()) {
$modx->cacheManager->set($cacheKey, $json, $cacheLifetime, $cacheOptions);
}
return $json;
Fähigkeiten:
- lokal oder extern (HTTPS) abrufbare ICS-Datei
- reiner JSON-Output
- Farbe über &color
- A.R.T. Müllkalender funktioniert
- externe URL (Beispiel):
https://art-trier.de/xxx-feed/xxxxx:xxxxxxxxx::@.ics
🕑 9. Snippet: fcSommerWinterJson
<?php
/**
* fcSommerWinterJson
* V 2025-11-24
*
* Erzeugt Events für Zeitumstellung (Sommer-/Winterzeit) für die nächsten X Jahre
* im JSON-Format für FullCalendar 6.
*
* Aufruf:
* [[!fcSommerWinterJson]]
*
* Optional:
* &yearsAhead=`20` // Standard: 20
*/
$yearsAhead = (int) $modx->getOption('yearsAhead', $scriptProperties, 20);
if ($yearsAhead < 0) {
$yearsAhead = 0;
}
// Farben wie in deinem alten Snippet
$colorS = '#138496'; // Sommer
$colorW = '#138496'; // Winter
$yearStart = (int) date('Y');
$events = [];
for ($i = 0; $i <= $yearsAhead; $i++) {
$year = $yearStart + $i;
// Letzter Sonntag im März = Start Sommerzeit
$sommer = new DateTime($year . '-03-31');
$sommer->modify('last sunday of this month');
$sommerStr = $sommer->format('Y-m-d');
// Letzter Sonntag im Oktober = Start Winterzeit
$winter = new DateTime($year . '-10-31');
$winter->modify('last sunday of this month');
$winterStr = $winter->format('Y-m-d');
$events[] = [
'start' => $sommerStr,
'end' => $sommerStr,
'title' => "Zeitumstellung Sommerzeit",
'color' => $colorS,
'url' => 'https://de.wikipedia.org/wiki/Sommerzeit',
'extendedProps' => [
'description' => 'Zeitumstellung auf Sommerzeit'
]
];
$events[] = [
'start' => $winterStr,
'end' => $winterStr,
'title' => "Zeitumstellung Winterzeit",
'color' => $colorW,
'url' => 'https://de.wikipedia.org/wiki/Winterzeit',
'extendedProps' => [
'description' => 'Zeitumstellung auf Winterzeit'
]
];
}
return json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Berechnet:
- Sommerzeit: letzter Sonntag im März
- Winterzeit: letzter Sonntag im Oktober
- für 20 Jahre
- mit eigener Link-URL
- eigene Farbe: #138496
🎌 10. Snippet: fcFeiertageJson
(gesetzliche Feiertage + bewegliche Feiertage)
<?php
/**
* fcFeiertageJson
* V 2025-11-24
*
* Liefert bewegliche + feste Feiertage und einige Ereignisse
* als JSON-Array für FullCalendar 6.
*
* Aufruf:
* [[!fcFeiertageJson]]
*
* Optional:
* &yearsAhead=`20` (Standard: 20)
*/
$yearsAhead = (int) $modx->getOption('yearsAhead', $scriptProperties, 20);
if ($yearsAhead < 0) {
$yearsAhead = 0;
}
$colorF = '#7e6025'; // Feiertage
$colorE = '#6B7C42'; // Brauchtum & Tradition (Ereignisse)
$yearStart = (int) date('Y');
$events = [];
for ($i = 0; $i <= $yearsAhead; $i++) {
$year = $yearStart + $i;
// Ostern berechnen (wie in deinem alten Code)
$base = new DateTime($year . '-03-21');
$days = easter_days($year);
$base->add(new DateInterval('P' . $days . 'D'));
$ostern = $base->format('Y-m-d');
// Hilfsfunktion für relative Tage zu Ostern
$rel = function (string $dateString, int $offsetDays): string {
$ts = strtotime($dateString . ' ' . ($offsetDays >= 0 ? '+' : '') . $offsetDays . ' day');
return date('Y-m-d', $ts);
};
// Bewegliche Termine (Karneval etc.)
$weiberfastnacht = $rel($ostern, -52);
$rosenmontag = $rel($ostern, -48);
$fastnacht = $rel($ostern, -47);
$aschermittwoch = $rel($ostern, -46);
$huettenbrennen = $rel($ostern, -42);
$karfreitag = $rel($ostern, -2);
$ostersonntag = $rel($ostern, 0);
$ostermontag = $rel($ostern, 1);
$christihimmelfahrt= $rel($ostern, 39);
$pfingstsonntag = $rel($ostern, 49);
$pfingstmontag = $rel($ostern, 50);
$fronleichnam = $rel($ostern, 60);
// Hilfsfunktion: Event hinzufügen
$addEvent = function(array &$events, string $start, string $end, string $title, string $color) {
$events[] = [
'start' => $start,
'end' => $end,
'title' => $title,
'color' => $color,
];
};
// Karneval / Hütt
$addEvent($events, $weiberfastnacht, $weiberfastnacht, 'Weiberfastnacht', $colorE);
$addEvent($events, $rosenmontag, $rosenmontag, 'Rosenmontag', $colorE);
$addEvent($events, $fastnacht, $fastnacht, 'Fastnacht', $colorE);
$addEvent($events, $aschermittwoch, $aschermittwoch, 'Aschermittwoch', $colorE);
$addEvent($events, $huettenbrennen, $huettenbrennen, 'Hüttenbrennen', $colorE);
// Feiertage um Ostern
$addEvent($events, $karfreitag, $karfreitag, 'Karfreitag', $colorF);
$addEvent($events, $ostersonntag, $ostersonntag, 'Ostersonntag', $colorF);
$addEvent($events, $ostermontag, $ostermontag, 'Ostermontag', $colorF);
$addEvent($events, $christihimmelfahrt, $christihimmelfahrt, 'Christi Himmelfahrt', $colorF);
$addEvent($events, $pfingstsonntag, $pfingstsonntag, 'Pfingstsonntag', $colorF);
$addEvent($events, $pfingstmontag, $pfingstmontag, 'Pfingstmontag', $colorF);
$addEvent($events, $fronleichnam, $fronleichnam, 'Fronleichnam', $colorF);
// Feste Feiertage / Ereignisse (wie in deinem Snippet)
$addEvent($events, "{$year}-01-01", "{$year}-01-01", 'Neujahr', $colorF);
$addEvent($events, "{$year}-01-05", "{$year}-01-05", 'Heilige Drei Könige', $colorF);
$addEvent($events, "{$year}-05-01", "{$year}-05-01", 'Tag der Arbeit', $colorF);
$addEvent($events, "{$year}-06-23", "{$year}-06-23", 'Nationalfeiertag in Luxemburg', $colorF);
$addEvent($events, "{$year}-08-15", "{$year}-08-15", 'Maria Himmelfahrt in Luxemburg', $colorF);
$addEvent($events, "{$year}-10-03", "{$year}-10-03", 'Tag der Deutschen Einheit', $colorF);
$addEvent($events, "{$year}-10-31", "{$year}-10-31", 'Reformationstag', $colorF);
$addEvent($events, "{$year}-11-01", "{$year}-11-01", 'Allerheiligen', $colorF);
$addEvent($events, "{$year}-11-11", "{$year}-11-11", 'Martinstag', $colorF);
$addEvent($events, "{$year}-12-06", "{$year}-12-06", 'Nikolaus', $colorF);
$addEvent($events, "{$year}-12-25", "{$year}-12-25", '1. Weihnachtsfeiertag', $colorF);
$addEvent($events, "{$year}-12-26", "{$year}-12-26", '2. Weihnachtsfeiertag', $colorF);
$addEvent($events, "{$year}-12-31", "{$year}-12-31", 'Silvester', $colorF);
}
return json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Berechnet:
- bewegliche Feiertage per Ostern-Formel
- fixe Feiertage
- Kirchenfeste
- farbcodiert (#7e6025)
🎉 11. Snippet: fcAddDaysJson
<?php
/**
* fcAddDaysJson
* V 2025-11-24
*
* Liest einen Chunk, der ein gültiges JSON-Array enthält,
* und gibt ihn 1:1 zurück.
*
* Standard-Chunkname: fcAddDays
*
* Aufruf:
* [[!fcAddDaysJson]]
* oder:
* [[!fcAddDaysJson? &chunk=`fcAddDaysJsonData`]]
*/
/** @var modX $modx */
$chunkName = $modx->getOption('chunk', $scriptProperties, 'fcAddDays');
$raw = trim($modx->getChunk($chunkName));
if ($raw === '') {
return '[]';
}
return $raw;
Diese Daten stammen aus meinem alten Chunk fcAddDays, jedoch komplett:
- normalisiert
- geprüft
- als JSON geliefert
Beispiel für den Chunk fcAddDays:
[[- Demo!
{"start":"2023-09-16T17:00:00","end":"2023-09-16T18:00:00","title":"Jahreshauptübung FFW\Mafiastr.42","description":"Siehe Blatt 42","url":"[[~2]]","color":"#6b7d3f"},
{"start":"2025-12-19T18:00:00","end":"2025-12-19T21:00:00","title":"Adventsbasar","description":"Siehe Blatt 66","url":"[[~81]]","color":"#6b7d3f"},
]]
[
{"start":"2025-04-14","end":"2025-04-25","allDay":true,"title":"Osterferien","color":"#7e2555"},
{"start":"2025-07-07","end":"2025-08-15","allDay":true,"title":"Sommerferien","color":"#7e2555"},
{"start":"2025-10-13","end":"2025-10-24","allDay":true,"title":"Herbstferien","color":"#7e2555"},
{"start":"2025-12-22","end":"2026-01-07","allDay":true,"title":"Weihnachtsferien","color":"#7e2555"},
{"start":"2026-03-30","end":"2026-04-10","allDay":true,"title":"Osterferien","color":"#7e2555"},
{"start":"2026-06-29","end":"2026-08-07","allDay":true,"title":"Sommerferien","color":"#7e2555"},
{"start":"2026-10-05","end":"2026-10-16","allDay":true,"title":"Herbstferien","color":"#7e2555"},
{"start":"2026-12-23","end":"2027-01-08","allDay":true,"title":"Weihnachtsferien","color":"#7e2555"},
{"start":"2027-03-22","end":"2027-04-02","allDay":true,"title":"Osterferien","color":"#7e2555"},
{"start":"2027-06-28","end":"2027-08-06","allDay":true,"title":"Sommerferien","color":"#7e2555"},
{"start":"2027-10-04","end":"2027-10-15","allDay":true,"title":"Herbstferien","color":"#7e2555"},
{"start":"2027-12-23","end":"2028-01-07","allDay":true,"title":"Weihnachtsferien","color":"#7e2555"}
]
☀️ 12. Snippet: fcWetterJson
<?php
/**
* fcWetterJson
*
* Baut aus der dwdWetter-JSON (daily-Array) Events für FullCalendar.
*/
$stationID = $modx->getOption('stationID', $scriptProperties, 'K428');
$days = (int)$modx->getOption('days', $scriptProperties, 10);
$defaultJsonDir = MODX_BASE_PATH . 'wetterdata/';
$jsonDir = $modx->getOption('jsonDir', $scriptProperties, $defaultJsonDir);
$dateField = $modx->getOption('dateField', $scriptProperties, 'date');
$tempField = $modx->getOption('tempField', $scriptProperties, 'tempDay');
$textField = $modx->getOption('textField', $scriptProperties, 'tooltip');
$iconBaseUrl = $modx->getOption('iconBaseUrl', $scriptProperties, '/assets/weather/');
// NEU: Resource für Detailansicht + ww-Feld
$weatherResourceId = (int)$modx->getOption('weatherResourceId', $scriptProperties, 0);
$wwField = $modx->getOption('wwField', $scriptProperties, '');
// URL zur Wetterdetail-Seite nur einmal berechnen
$weatherUrl = '';
if ($weatherResourceId > 0) {
$weatherUrl = $modx->makeUrl($weatherResourceId, '', '', 'full');
}
$file = rtrim($jsonDir, '/\\') . DIRECTORY_SEPARATOR . 'wetter_' . $stationID . '.json';
if (!file_exists($file)) {
return json_encode([], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$json = file_get_contents($file);
$data = json_decode($json, true);
if (!is_array($data) || empty($data['daily']) || !is_array($data['daily'])) {
return json_encode([], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$daily = array_slice($data['daily'], 0, $days);
$events = [];
foreach ($daily as $idx => $row) {
if (!is_array($row)) {
continue;
}
// Datum
$dateRaw = $row[$dateField] ?? null;
if (!$dateRaw) {
continue;
}
$date = substr($dateRaw, 0, 10);
// Icon
$mainIcon = !empty($row['mainIcon']) ? $row['mainIcon'] : 'unknown.png';
if (preg_match('#^(https?://|/)#i', $mainIcon)) {
$iconUrl = $mainIcon;
} else {
$iconUrl = rtrim($iconBaseUrl, '/').'/'.$mainIcon;
}
// Temperatur
$tempLabel = '';
if (isset($row[$tempField])) {
$tempVal = $row[$tempField];
if (is_numeric($tempVal)) {
$tempLabel = round($tempVal, 1).' °C';
} else {
$tempLabel = (string)$tempVal;
}
}
// Tooltip / Titel
$tooltip = '';
if (!empty($row[$textField])) {
$tooltip = (string)$row[$textField];
} elseif (!empty($row['summary'])) {
$tooltip = (string)$row['summary'];
} elseif (!empty($row['description'])) {
$tooltip = (string)$row['description'];
}
// NEU: dwdWetterConditionDe aus ww-Code verwenden,
// wenn bisher noch kein Tooltip gesetzt wurde
if ($tooltip === '' && $wwField !== '' && !empty($row[$wwField])) {
$tooltip = $modx->runSnippet('dwdWetterConditionDe', [
'code' => $row[$wwField],
'default' => 'Wettervorhersage '.$date,
]);
}
// Fallback
if ($tooltip === '') {
$tooltip = 'Wettervorhersage '.$date;
}
$events[] = [
'id' => 'wx-' . $stationID . '-' . $date,
'title' => $tooltip,
'start' => $date,
'allDay' => true,
'url' => $weatherUrl ?: null, // Klick öffnet Resource ID
'extendedProps' => [
'type' => 'weather',
'stationID' => $data['stationID'] ?? $stationID,
'stationName' => $data['stationName'] ?? '',
'icon' => $iconUrl,
'temp' => $tempLabel,
],
];
}
return json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Dieses Snippet:
- Ermittelt die Datei:
wetter_[stationID].jsonim VerzeichnisjsonDir - Lädt und dekodiert die JSON (Array mit
daily) - Baut aus jedem
daily-Eintrag genau ein FullCalendar-Event - Liefert am Ende ein JSON-Array zurück, das von FullCalendar als
eventSourcebenutzt wird
Erwartete Felder im daily-Array (Beispiel):
dateISO→ Datum, z.B.2025-12-01tempMean→ Tages-Temperatur (Mittelwert)mainIcon→ Dateiname oder URL zum Wetter-IconmainSigWeatherCode→ DWD-ww-Code (0–100)- optional:
summary,descriptionoder eigene Textfelder
Logik in Kurzform (Beschreibung):
- Datum:
- aus
dateField(z.B.dateISO) → aufYYYY-MM-DDkürzen
- aus
- Icon:
- wenn
mainIconmit/oderhttpbeginnt → direkt nutzen - sonst:
iconBaseUrl + mainIcon
- wenn
- Temperatur:
- aus
tempField(z.B.tempMean) → gerundet +" °C"
- aus
- Tooltip/Text:
- falls
textFieldgesetzt und in der JSON vorhanden → diesen Text verwenden - sonst, wenn
wwFieldgesetzt und Wert vorhanden →dwdWetterConditionDeaufrufen mitmainSigWeatherCode - Fallback:
"Wettervorhersage YYYY-MM-DD"
- falls
- Klickziel:
- wenn
weatherResourceId> 0 →makeUrl(weatherResourceId)→ inevent.urleintragen
- wenn
- Event-Struktur für FullCalendar (logisch, nicht als Code):
id: eindeutige ID, z.B."wx-[stationID]-[Datum]"title: Tooltip-Text (Ergebnis vondwdWetterConditionDeoder Fallback)start: DatumYYYY-MM-DDallDay:trueurl: Link zur Wetter-Detail-Resource (z.B. ID 42)extendedProps:type:weatherstationID,stationNameicon: Icon-URLtemp: Temperatur-Label
💅 13. Eigene CSS-Anpassungen
In eigenes CSS:
/* FULLCalendar */
.fc .fc-event-title {
white-space: normal !important;
overflow-wrap: anywhere;
line-height: 1.2;
}
@media (max-width: 600px) {
/* Buttons in der FullCalendar-Toolbar verkleinern */
.fc .fc-toolbar-chunk .btn {
padding: 2px 6px;
font-size: 0.75rem;
line-height: 1.1;
}
/* Optional: Abstand zwischen Buttons leicht reduzieren */
.fc .fc-toolbar-chunk .btn-group .btn {
margin-right: 2px;
}
}
/* Standard: Events erstmal "neutral" lassen */
.fc a.fc-event {
cursor: default;
text-decoration: none;
}
/* Nur Events mit href als Link behandeln */
.fc a.fc-event[href] {
cursor: pointer;
transition: filter 0.15s ease, opacity 0.15s ease;
}
/* Hover-Effekt NUR bei Events mit href */
.fc a.fc-event[href]:hover {
filter: brightness(1.15);
opacity: 0.9;
}
/* Titel nur bei klickbaren Events unterstreichen */
.fc a.fc-event[href] .fc-event-title {
text-decoration: underline;
}
/* Wetter-Events im Kalender */
.fc-event-weather {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
.fc-weather-event {
display: flex;
flex-direction: column;
align-items: flex-start; /* links ausrichten */
justify-content: flex-start;
padding: 0.2rem 0.2rem;
}
.fc-weather-icon {
max-width: 48px; /* evtl. anpassen */
max-height: 48px;
display: block;
margin-bottom: 2px;
}
.fc-weather-temp {
font-size: 0.8rem;
line-height: 1.1;
white-space: nowrap;
}
🧪 14. Tests
1. JSON-Feed testen:
https://deine-domain.tld/kalender-events.json
https://deine-domain.tld/kalender-feiertage.json
https://deine-domain.tld/kalender-sommerwinter.json
https://deine-domain.tld/kalender-adddays.json
https://deine-domain.tld/ics-muell-events.json
https://deine-domain.tld/kalender-wetter.json.json
2. Kalenderseite testen:
- werden alle Farben angezeigt?
- werden Ferien/Feiertage korrekt angezeigt?
- Tooltip funktioniert?
- Wischen links/rechts auf Handy/Tablet?
- Blättern mit den Tasten Links/Rechts?
- Links öffnen sich bei Klick?
✔️ Ergebnis
Du hast jetzt:
- FullCalendar 6.1.19
- Bootstrap 5 Theme
- TV-basierte Termine
- ICS-Import via JSON
- Ferien/Feiertage
- Sommer/Winterzeit
- Wetter Icon pro Tag (PNG mit Transparenz)
- Manuelle Ereignisse
- Smartphone-Navigation
- Tooltip
- farbliche Legende
- 100% valides HTML ohne RTE-Fehler
Perfekte Lösung für einen Dorfkalender.
Anhang
- Zur besseren Übersicht habe ich die JSON-Feeds für den FullCalendar in eine eigene Ressource ausgelagert.
- Damit bleibt die Struktur klarer, und der geparste Inhalt lässt sich leichter nachvollziehen.
- Die URLs wurden im Snippet
fcRegisterAssetsentsprechend angepasst.

