Skip to main content

⏳ Countdown

Bootstrap-5-Countdown

Blog-Teaser_Artikel.png

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= oder tvStartdatum.
  • Mehrere Termine über || möglich.
  • Ungültige Werte oder Jahre kleiner als &minYear werden 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.