Skip to main content

📆 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.

FullCalendar_klein.jpg

☀️ 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


🗂️ 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">&nbsp;</span>
    Termine mit Artikel/Info-Link<br>

    <span class="badge bg-success me-1">&nbsp;</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;">&nbsp;</span>
    Schulferien Rheinland-Pfalz<br>

    <span class="badge me-1" style="background-color: #7e6025;">&nbsp;</span>
    Feiertage und besondere kirchliche Tage<br>

    <span class="badge me-1" style="background-color: #6b7d3f;">&nbsp;</span>
    Brauchtum & Tradition<br>

    <span class="badge me-1" style="background-color: #138496;">&nbsp;</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, ohne color, damit kein farbiger Hintergrund)

Wer /kalender-wetter.json nicht 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>
    • icon und temp kommen aus extendedProps
  • Für alle anderen Events:
    • Standard-Event-Titel anzeigen (z.B. <div class="fc-event-title">[title]</div>)

eventDidMount (Tooltip):

  • wenn bootstrap.Tooltip verfügbar:
    • Tooltip mit title = info.event.title
    • der Text kommt aus dwdWetterConditionDe (via fcWetterJson)
  • sonst:
    • title-Attribut setzen

eventClick:

  • wenn event.url gesetzt → in _self öffnen
    • z.B. die Wetter-Detailseite (Resource-ID 42)

datesSet:

  • aktuelles View-Datum (Monatsmitte) im localStorage unter fcLastDate speichern
  • 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-Ordner
  • tempField → Feldname für Tages-Temperatur im daily-Array
  • iconBaseUrl → URL-Basis der Wetter-Icons (PNG, transparent)
  • weatherResourceId → ID der Detailseite, die bei Klick geöffnet werden soll
  • wwField → Feldname für den DWD-ww-Code (hier mainSigWeatherCode

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:MM oder YYYY-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-28
  • 2025-11-28 10:00
  • 2025-11-28 10:00:00
  • 2025-11-28 || 2025-11-30
  • 2025-11-28 10:00 || 2025-11-30 22:00:00
  • 2025-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

  • tvStartdatum und tvEnddatum kö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 tvStartdatum erzeugt ein eigenes Event.
  • tvEnddatum wird 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('&amp;', '&', $strTemp);

        $current['title'] = $strTemp;
        continue;
    }

    if (str_starts_with($line, $strSearchDESC)) {
        $strTemp = substr($line, strlen($strSearchDESC));
        $strTemp = htmlspecialchars(trim($strTemp));
        $strTemp = str_replace('&amp;', '&', $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:

  1. Ermittelt die Datei:
    wetter_[stationID].json im Verzeichnis jsonDir
  2. Lädt und dekodiert die JSON (Array mit daily)
  3. Baut aus jedem daily-Eintrag genau ein FullCalendar-Event
  4. Liefert am Ende ein JSON-Array zurück, das von FullCalendar als eventSource benutzt wird

Erwartete Felder im daily-Array (Beispiel):

  • dateISO → Datum, z.B. 2025-12-01
  • tempMean → Tages-Temperatur (Mittelwert)
  • mainIcon → Dateiname oder URL zum Wetter-Icon
  • mainSigWeatherCode → DWD-ww-Code (0–100)
  • optional: summary, description oder eigene Textfelder

Logik in Kurzform (Beschreibung):

  • Datum:
    • aus dateField (z.B. dateISO) → auf YYYY-MM-DD kürzen
  • Icon:
    • wenn mainIcon mit / oder http beginnt → direkt nutzen
    • sonst: iconBaseUrl + mainIcon
  • Temperatur:
    • aus tempField (z.B. tempMean) → gerundet + " °C"
  • Tooltip/Text:
    • falls textField gesetzt und in der JSON vorhanden → diesen Text verwenden
    • sonst, wenn wwField gesetzt und Wert vorhanden → dwdWetterConditionDe aufrufen mit mainSigWeatherCode
    • Fallback: "Wettervorhersage YYYY-MM-DD"
  • Klickziel:
    • wenn weatherResourceId > 0 → makeUrl(weatherResourceId) → in event.url eintragen
  • Event-Struktur für FullCalendar (logisch, nicht als Code):
    • id: eindeutige ID, z.B. "wx-[stationID]-[Datum]"
    • title: Tooltip-Text (Ergebnis von dwdWetterConditionDe oder Fallback)
    • start: Datum YYYY-MM-DD
    • allDay: true
    • url: Link zur Wetter-Detail-Resource (z.B. ID 42)
    • extendedProps:
      • type: weather
      • stationID, stationName
      • icon: Icon-URL
      • temp: 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 fcRegisterAssets entsprechend angepasst.

jsonList.png