⏳ Countdown
Bootstrap-5-Countdown
Snippet fcCountdown
Aufruf: [[!fcCountdown? &minYear=`2025` &align=`right` &tpl=`fcCountdownTpl`]]
<?php
/**
* Snippet: fcCountdown
* V 2025-11-26 + Separator
* - Zeigt einen Bootstrap-5-Countdown unter dem Seiten-Content.
* - Zählt bis zum Termin runter und danach wieder hoch („Seit …“) – für Einzeldatum.
* - Bei mehreren Terminen (Separator, Standard: ||) wird immer der nächste kommende Termin verwendet.
* - Datum kommt aus:
* - &date=`` (optional, ISO: 2025-12-24 18:00, kann auch mehrere enthalten)
* - sonst TV tvStartdatum der aktuellen Ressource (ebenfalls optional mehrere)
* - Mindestjahr per &minYear=`` (Standard: aktuelles Jahr)
* - Ausrichtung per &align=`left|center|right`
* - Separator per &separator=`` (Standard: ||)
*
* Aufruf z.B. im Template:
* [[!fcCountdown? &minYear=`2025` &align=`right` &tpl=`fcCountdownTpl`]]
* [[!fcCountdown? &minYear=`2025` &align=`right` &tpl=`fcCountdownTpl` &separator=`||`]]
*/
/** @var modX $modx */
$resource = $modx->resource;
if (!$resource) {
return '';
}
# Parameter
$dateParam = trim($modx->getOption('date', $scriptProperties, ''));
$minYear = (int) $modx->getOption('minYear', $scriptProperties, date('Y'));
$align = strtolower(trim($modx->getOption('align', $scriptProperties, 'right')));
$tpl = $modx->getOption('tpl', $scriptProperties, 'fcCountdownTpl');
# Separator für Mehrfach-Daten (Standard: ||)
$sep = $modx->getOption('separator', $scriptProperties, '||');
# Ausrichtung auf Bootstrap-Klasse mappen
switch ($align) {
case 'left':
$justify = 'justify-content-start';
break;
case 'center':
$justify = 'justify-content-center';
break;
default:
$justify = 'justify-content-end';
break;
}
# Datum bestimmen: Param nimmt Vorrang, sonst TV
$startRaw = $dateParam !== '' ? $dateParam : trim((string) $resource->getTVValue('tvStartdatum'));
# Kein Datum -> nichts anzeigen
if ($startRaw === '') {
return '';
}
# Mehrere Datumswerte unterstützen: Trennung per konfiguriertem Separator
$rawParts = array_map('trim', explode($sep, $startRaw));
# Valide DateTime-Objekte aufbauen (mit minYear-Filter)
$dates = [];
foreach ($rawParts as $part) {
if ($part === '') {
continue;
}
try {
$dt = new DateTime($part);
} catch (Exception $e) {
continue;
}
if ((int)$dt->format('Y') < $minYear) {
continue;
}
$dates[] = [
'raw' => $part,
'dt' => $dt,
];
}
# Wenn keine gültigen Daten übrig sind -> nichts anzeigen
if (empty($dates)) {
return '';
}
# Single- oder Multi-Modus bestimmen
$isMulti = count($dates) > 1;
# ISO-Strings vorbereiten
$now = new DateTime();
if ($isMulti) {
# Alle ISO-Daten für data-dates
$isoList = [];
foreach ($dates as $item) {
$isoList[] = str_replace(' ', 'T', $item['raw']);
}
$dataDates = implode($sep, $isoList);
# Für data-start einen sinnvollen „Primär“-Wert setzen:
# -> nächstes zukünftiges Datum; falls nichts in Zukunft, das späteste vergangene
$primary = null;
foreach ($dates as $item) {
if ($item['dt'] >= $now && ($primary === null || $item['dt'] < $primary['dt'])) {
$primary = $item;
}
}
if ($primary === null) {
# alles liegt in der Vergangenheit -> nimm das späteste Datum als Fallback
foreach ($dates as $item) {
if ($primary === null || $item['dt'] > $primary['dt']) {
$primary = $item;
}
}
}
$startDt = $primary['dt'];
$startIso = str_replace(' ', 'T', $primary['raw']);
$dataDatesAttr = ' data-dates="' . htmlspecialchars($dataDates, ENT_QUOTES, "UTF-8") . '"';
} else {
# Nur ein Datum -> verhalte dich wie bisher
$startDt = $dates[0]['dt'];
$startIso = str_replace(' ', 'T', $dates[0]['raw']);
$dataDatesAttr = '';
}
# Mindestjahr prüfen (alle Kandidaten wurden bereits gefiltert, aber zur Sicherheit)
if ((int)$startDt->format('Y') < $minYear) {
return '';
}
# Einfache ID für diesen Countdown
$uid = 'modx_cd_' . $resource->get('id');
# Platzhalter für den Chunk vorbereiten
$placeholders = [
'uid' => $uid,
'uid_json' => json_encode($uid),
'justify' => $justify,
'startIsoEsc' => htmlspecialchars($startIso, ENT_QUOTES, "UTF-8"),
'sepEsc' => htmlspecialchars($sep, ENT_QUOTES, "UTF-8"),
'dataDatesAttr'=> $dataDatesAttr,
];
# Chunk rendern (Chunk-Name nach Bedarf anpassen)
return $modx->getChunk($tpl, $placeholders);
Chunk fcCountdownTpl
<div class="d-flex [[+justify]] mt-3">
<div id="[[+uid]]" class="card shadow-sm small"
data-start="[[+startIsoEsc]]"
data-label="Countdown"[[+dataDatesAttr]]
data-sep="[[+sepEsc]]">
<div class="card-body py-2 px-3">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-hourglass-split me-1"></i>
<strong class="modx-countdown-title">Countdown</strong>
</div>
<div class="d-flex gap-3 modx-countdown-values">
<span><span class="modx-cd-days">--</span> Tage</span>
<span><span class="modx-cd-hours">--</span> Std</span>
<span><span class="modx-cd-mins">--</span> Min</span>
<span><span class="modx-cd-secs">--</span> Sek</span>
</div>
</div>
</div>
</div>
<script>
(function() {
var box = document.getElementById([[+uid_json]]);
if (!box) return;
var startStr = box.getAttribute("data-start");
if (!startStr) return;
// Separator für Multi-Daten (Feld data-sep, Fallback: ||)
var sep = box.getAttribute("data-sep") || "||";
// Multi-Datum-Unterstützung: data-dates lesen (wenn vorhanden)
var datesAttr = box.getAttribute("data-dates");
var dates = [];
if (datesAttr) {
datesAttr.split(sep).forEach(function(s) {
s = s.trim();
if (!s) return;
var d = new Date(s);
if (!isNaN(d.getTime())) {
dates.push(d);
}
});
}
var isMulti = dates.length > 1;
// In valides JS-Datum bringen (Single-Fallback)
var startTime = new Date(startStr);
if (isNaN(startTime.getTime())) {
// Fallback: Leer anzeigen
return;
}
var titleEl = box.querySelector(".modx-countdown-title");
var dEl = box.querySelector(".modx-cd-days");
var hEl = box.querySelector(".modx-cd-hours");
var mEl = box.querySelector(".modx-cd-mins");
var sEl = box.querySelector(".modx-cd-secs");
if (!titleEl || !dEl || !hEl || !mEl || !sEl) return;
function render(diffMs, mode) {
// diffMs ist immer positiv
var totalSeconds = Math.floor(diffMs / 1000);
var days = Math.floor(totalSeconds / 86400);
var rest = totalSeconds % 86400;
var hours = Math.floor(rest / 3600);
rest = rest % 3600;
var mins = Math.floor(rest / 60);
var secs = rest % 60;
dEl.textContent = days;
hEl.textContent = hours.toString().padStart(2, "0");
mEl.textContent = mins.toString().padStart(2, "0");
sEl.textContent = secs.toString().padStart(2, "0");
if (mode === "down") {
// Noch X Tage …
titleEl.textContent = "Countdown";
} else {
// Seit X Tagen …
titleEl.textContent = "Seit dem Ereignis";
}
}
function getNextTarget(now) {
var best = null;
for (var i = 0; i < dates.length; i++) {
var d = dates[i];
if (d.getTime() >= now.getTime()) {
if (best === null || d.getTime() < best.getTime()) {
best = d;
}
}
}
return best;
}
function tickMulti() {
var now = new Date();
var target = getNextTarget(now);
if (!target) {
// Keine zukünftigen Termine mehr -> Countdown ausblenden
box.style.display = "none";
return false;
}
var diff = target.getTime() - now.getTime(); // >= 0
render(diff, "down");
return true;
}
function tickSingle() {
var now = new Date();
var diff = startTime.getTime() - now.getTime();
if (diff >= 0) {
// Noch Zeit bis zum Termin → normaler Countdown
render(diff, "down");
} else {
// Termin liegt in der Vergangenheit → seit wann?
render(-diff, "up");
}
return true;
}
function tick() {
if (isMulti) {
if (!tickMulti()) {
// wenn tickMulti false zurückgibt, gibt es keine zukünftigen Events mehr
clearInterval(timerId);
}
} else {
tickSingle();
}
}
tick();
var timerId = setInterval(tick, 1000);
})();
</script>
Snippet fcCountdownRow
Aufruf: [[!fcCountdownRow? &date=`[[+tvStartdatum]]` &minYear=`2025` &align=`right` &tpl=`fcCountdownRowTpl`]]
Zweck: Kleiner Countdown im Blog-Teaser (pdoResources-Zeilen)
<?php
/** @var modX $modx */
/*
* Snippet: fcCountdownRow
* V 2025-11-26 + Separator
* Zweck: Kleiner Countdown im Blog-Teaser (pdoResources-Zeilen)
*
* Aufruf-Beispiel:
* [[!fcCountdownRow? &date=`[[+tvStartdatum]]` &minYear=`2025` &align=`right` &tpl=`fcCountdownRowTpl`]]
*
* Optional mit Separator:
* [[!fcCountdownRow? &date=`[[+tvStartdatum]]` &minYear=`2025` &align=`right` &tpl=`fcCountdownRowTpl` &separator=`||`]]
*/
$dateStr = trim($modx->getOption('date', $scriptProperties, ''));
$minYear = (int)$modx->getOption('minYear', $scriptProperties, 2025);
$align = strtolower($modx->getOption('align', $scriptProperties, 'right'));
$sep = $modx->getOption('separator', $scriptProperties, '||');
$tpl = $modx->getOption('tpl', $scriptProperties, 'fcCountdownRowTpl');
// Kein Datum → nix anzeigen
if ($dateStr === '') {
return '';
}
$now = time();
$targetTs = null;
// Prüfen, ob Mehrfach-Daten per Separator vorliegen
if (strpos($dateStr, $sep) !== false) {
// Mehrere Datumswerte: nächstes zukünftiges wählen
$parts = array_map('trim', explode($sep, $dateStr));
foreach ($parts as $part) {
if ($part === '') {
continue;
}
$ts = strtotime($part);
if ($ts === false) {
continue;
}
$year = (int)date('Y', $ts);
if ($year < $minYear) {
continue;
}
if ($ts <= $now) {
continue;
}
if ($targetTs === null || $ts < $targetTs) {
$targetTs = $ts;
}
}
// Kein gültiger zukünftiger Termin gefunden
if ($targetTs === null) {
return '';
}
} else {
// Einzel-Datum wie bisher
$timestamp = strtotime($dateStr);
if ($timestamp === false) {
return '';
}
// Jahr prüfen (z.B. nur ab 2025)
$year = (int)date('Y', $timestamp);
if ($year < $minYear) {
return '';
}
// Nur zukünftige Events anzeigen
$diffCheck = $timestamp - $now;
if ($diffCheck <= 0) {
return '';
}
$targetTs = $timestamp;
}
// Zeitdifferenz aufdröseln (immer zu $targetTs)
$diff = $targetTs - $now;
$days = floor($diff / 86400);
$diff -= $days * 86400;
$hours = floor($diff / 3600);
$diff -= $hours * 3600;
$mins = floor($diff / 60);
// Deutsche Texte
if ($days > 0) {
$text = 'Noch ' . $days . ' Tag' . ($days === 1 ? '' : 'e');
} elseif ($hours > 0) {
$text = 'Noch ' . $hours . ' Stunde' . ($hours === 1 ? '' : 'n');
} else {
$text = 'Noch ' . $mins . ' Minute' . ($mins === 1 ? '' : 'n');
}
// Ausrichtung in Bootstrap-Klassen umsetzen
switch ($align) {
case 'left':
$alignClass = 'text-start';
break;
case 'center':
case 'centre':
$alignClass = 'text-center';
break;
case 'right':
default:
$alignClass = 'text-end';
break;
}
// Platzhalter für den Chunk
$placeholders = [
'alignClass' => $alignClass,
'text' => $text,
];
return $modx->getChunk($tpl, $placeholders);
Chunk fcCountdownRowTpl
<div class="mt-2 [[+alignClass]]">
<span class="badge bg-light text-dark border small">
<i class="bi bi-hourglass-split me-1"></i>[[+text]]
</span>
</div>
Countdown – Kurzüberblick
Eingabe
- Quelle ist entweder
&date=odertvStartdatum. - Mehrere Termine über
||möglich. - Ungültige Werte oder Jahre kleiner als
&minYearwerden verworfen.
Verhalten
- Bei EINEM Datum: normales Verhalten (runterzählen, danach „Seit dem Ereignis“).
- Bei MEHREREN Daten:
- Alle gültigen Datumswerte werden gesammelt.
- Vergangene Termine werden ignoriert.
- Der Countdown nutzt immer den nächsten zukünftigen Termin.
- Wenn kein zukünftiger Termin mehr existiert → Countdown ausgeblendet.
Beispiel
- Startdaten: 02.06., 14.07., 02.08.
- Heute: 17.06.
- Countdown zeigt korrekt auf 14.07., da dieses Datum als nächstes bevorsteht.
