Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider

Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte
eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte;
dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein
einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah.

Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür
vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante:
links der Aufmacher (match-feature), rechts die folgenden Termine als
verlinkte match-row-Zeilen ohne Kartenfläche.

Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast,
linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei
typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile
mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder
Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über
font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch.

match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen
jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist.
Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin,
weil PHP global auf UTC läuft) und match_competition_label().

match-slider.php und match-card.php entfallen — Letztere wurde
ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den
Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:39:57 +02:00
parent bee19a2ddc
commit 66c402d9f7
10 changed files with 461 additions and 401 deletions

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
/**
* Einzelnes Spiel (Begegnung) als Karte. Props:
* $match Eintrag aus data/matchcenter.json (next_matches[]/last_results[]/upcoming[])
* $variant '' (Default) | 'hero' (groß, für den Slider)
* Aufbau (kicker-Stil): Datum oben mittig, darunter Heim | Ergebnis/Anstoß | Gast,
* die Zeit mittig unter dem Ergebnis. Die Heimmannschaft steht immer links — daher
* kein zusätzlicher Heim/Auswärts-Hinweis. Die eigene Mannschaft wird hervorgehoben.
*/
$variant = $variant ?? '';
$m = $match;
$hasResult = isset($m['goals_home'], $m['goals_away']);
$isHome = !empty($m['is_home']);
$classes = 'match-card';
if ($variant !== '') {
$classes .= ' match-card--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant));
}
?>
<article class="<?= $classes ?>">
<?php if (!empty($m['date_display'])): ?>
<p class="match-card__date"><?= icon('calendar-event', 'match-card__date-icon') ?><?= e($m['date_display']) ?></p>
<?php endif; ?>
<div class="match-card__fixture">
<div class="match-card__side<?= $isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
<span class="match-card__club"><?= e($m['home'] ?? '') ?></span>
</div>
<div class="match-card__center">
<?php if ($hasResult): ?>
<span class="match-card__score"><?= (int) $m['goals_home'] ?><span class="match-card__colon">:</span><?= (int) $m['goals_away'] ?></span>
<?php else: ?>
<span class="match-card__vs" aria-label="gegen">:</span>
<?php endif; ?>
<?php if (!empty($m['time_display'])): ?>
<span class="match-card__time"><?= e($m['time_display']) ?></span>
<?php endif; ?>
</div>
<div class="match-card__side<?= !$isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
<span class="match-card__club"><?= e($m['away'] ?? '') ?></span>
</div>
</div>
</article>

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Aufmacher des „Nächste Spiele"-Bands: das zeitlich nächste Spiel — strukturgleich
* mit match-row (Heim über Gast, linksbündig), nur groß. Ein Bauprinzip für das
* ganze Band; die Wirkung kommt aus der Größe, nicht aus Badges oder Icons.
* Drei Schriftrollen: Meta-Zeile (Abel, gesperrt) · Vereinsnamen (Coolvetica) ·
* Anstoßzeile (Abel). Props:
* $match Eintrag aus data/matchcenter.json → upcoming[]
* $team_name Name der eigenen Mannschaft („1. Mannschaft")
* $league Liganame der Mannschaft (für die Meta-Zeile)
* $href Sprungmarke zur Mannschafts-Sektion (optional; macht den
* Mannschaftsnamen in der Meta-Zeile zum Link)
*/
$m = $match;
$isHome = !empty($m['is_home']);
$teamName = (string) ($team_name ?? '');
$league = (string) ($league ?? '');
$href = $href ?? null;
$when = match_when((string) ($m['kickoff'] ?? ''));
// Meta-Zeile: Mannschaft · Wettbewerb · Heim/Auswärts (leere Teile fallen weg).
$competition = match_competition_label((string) ($m['competition'] ?? ''), $league);
$meta = array_values(array_filter([
$teamName,
$competition,
$isHome ? 'Heimspiel' : 'Auswärtsspiel',
], static fn (string $part): bool => $part !== ''));
$datetime = trim(($m['date_display'] ?? '') . ' · ' . ($m['time_display'] ?? ''), ' ·');
?>
<article class="match-feature">
<?php if ($meta !== []): ?>
<p class="match-feature__meta">
<?php foreach ($meta as $i => $part): ?>
<?php if ($i > 0): ?>
<span class="match-feature__sep" aria-hidden="true">·</span>
<?php endif; ?>
<?php if ($i === 0 && $href !== null): ?>
<a href="<?= e($href) ?>"><?= e($part) ?></a>
<?php else: ?>
<span><?= e($part) ?></span>
<?php endif; ?>
<?php endforeach; ?>
</p>
<?php endif; ?>
<div class="match-feature__fixture">
<span class="match-feature__side<?= $isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
<span class="match-feature__club"><?= e($m['home'] ?? '') ?></span>
</span>
<span class="match-feature__side<?= !$isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
<span class="match-feature__club"><?= e($m['away'] ?? '') ?></span>
</span>
</div>
<p class="match-feature__when">
<?php if ($when['iso'] !== ''): ?>
<time datetime="<?= e($when['iso']) ?>"><?= e($datetime) ?></time>
<?php else: ?>
<span><?= e($datetime) ?></span>
<?php endif; ?>
<?php if ($when['countdown'] !== ''): ?>
<span class="match-feature__sep" aria-hidden="true">·</span>
<span class="match-feature__countdown" data-countdown="<?= e($when['iso']) ?>"><?= e($when['countdown']) ?></span>
<?php endif; ?>
</p>
</article>

View File

@@ -3,36 +3,60 @@
declare(strict_types=1);
/**
* Kompakte Spielzeile für die Matchcenter-Seitenspalte (kicker-Stil): Datum oben,
* darunter Heim über Gast (Wappen + Name, lange Namen werden mit … gekürzt), rechts
* das Ergebnis bzw. — bei anstehenden Spielen — die Anstoßzeit. Heim steht immer
* oben/links. Props: $match (Eintrag aus data/matchcenter.json).
* Kompakte Spielzeile (kicker-Stil): Datum oben, darunter Heim über Gast (Wappen +
* Name, lange Namen werden mit … gekürzt), rechts das Ergebnis bzw. — bei
* anstehenden Spielen — die Anstoßzeit. Heim steht immer oben. Props:
* $match Eintrag aus data/matchcenter.json (next_matches[]/last_results[]/upcoming[])
* $meta optionale Angabe neben dem Datum (im „Nächste Spiele"-Band: die Mannschaft)
* $href optionales Ziel — dann ist die ganze Zeile ein Link
* Der innere __link-Wrapper trägt Raster und Innenabstand, damit als Link die
* komplette Zeilenfläche klickbar ist (und ohne Link nichts anders aussieht).
*/
$m = $match;
$hasResult = isset($m['goals_home'], $m['goals_away']);
$isHome = !empty($m['is_home']);
$meta = (string) ($meta ?? '');
$href = $href ?? null;
?>
<li class="match-row">
<?php if (!empty($m['date_display'])): ?>
<span class="match-row__date"><?= e($m['date_display']) ?></span>
<?php endif; ?>
<div class="match-row__teams">
<span class="match-row__team<?= $isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
<span class="match-row__name" title="<?= e($m['home'] ?? '') ?>"><?= e($m['home'] ?? '') ?></span>
</span>
<span class="match-row__team<?= !$isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
<span class="match-row__name" title="<?= e($m['away'] ?? '') ?>"><?= e($m['away'] ?? '') ?></span>
</span>
</div>
<span class="match-row__outcome">
<?php if ($hasResult): ?>
<span class="match-row__score"><?= (int) $m['goals_home'] ?>:<?= (int) $m['goals_away'] ?></span>
<?php elseif (!empty($m['time_display'])): ?>
<span class="match-row__time"><?= e($m['time_display']) ?></span>
<?php if ($href !== null): ?>
<a class="match-row__link" href="<?= e($href) ?>">
<?php else: ?>
<span class="match-row__time" aria-hidden="true"></span>
<div class="match-row__link">
<?php endif; ?>
<?php if (!empty($m['date_display']) || $meta !== ''): ?>
<span class="match-row__head">
<?php if (!empty($m['date_display'])): ?>
<span class="match-row__date"><?= e($m['date_display']) ?></span>
<?php endif; ?>
<?php if ($meta !== ''): ?>
<span class="match-row__meta"><?= e($meta) ?></span>
<?php endif; ?>
</span>
<?php endif; ?>
<span class="match-row__teams">
<span class="match-row__team<?= $isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
<span class="match-row__name" title="<?= e($m['home'] ?? '') ?>"><?= e($m['home'] ?? '') ?></span>
</span>
<span class="match-row__team<?= !$isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
<span class="match-row__name" title="<?= e($m['away'] ?? '') ?>"><?= e($m['away'] ?? '') ?></span>
</span>
</span>
<span class="match-row__outcome">
<?php if ($hasResult): ?>
<span class="match-row__score"><?= (int) $m['goals_home'] ?>:<?= (int) $m['goals_away'] ?></span>
<?php elseif (!empty($m['time_display'])): ?>
<span class="match-row__time"><?= e($m['time_display']) ?></span>
<?php else: ?>
<span class="match-row__time" aria-hidden="true"></span>
<?php endif; ?>
</span>
<?php if ($href !== null): ?>
<span class="visually-hidden"> zur Mannschaft</span>
</a>
<?php else: ?>
</div>
<?php endif; ?>
</span>
</li>

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
/**
* „Nächste Spiele"-Slider (teamübergreifend). Props: $matches (data/matchcenter.json
* → upcoming[]). Spiegelt die Mechanik des banner-slider (Crossfade, Pause/Play,
* prefers-reduced-motion) über den generischen Antrieb assets/js/carousel.js.
* Ohne JS ist nur die erste Folie sichtbar. Leer → nichts rendern (Off-Season).
*/
$matches = $matches ?? [];
if ($matches === []) {
return;
}
$total = count($matches);
?>
<section class="section match-slider" id="naechste-spiele" aria-roledescription="Karussell" aria-label="Nächste Spiele unserer Mannschaften" data-carousel>
<div class="container">
<h2>Nächste Spiele</h2>
<ul class="match-slider__track" id="match-slider-track" data-carousel-track aria-live="off">
<?php foreach ($matches as $i => $m): ?>
<li class="match-slider__slide" data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
<p class="match-slider__team"><?= e($m['team_name'] ?? '') ?></p>
<?php component('match-card', ['match' => $m, 'variant' => 'hero']); ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($total > 1): ?>
<div class="match-slider__controls" data-carousel-controls hidden>
<button class="match-slider__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="match-slider-track" hidden>
<?= icon('pause-fill', 'match-slider__toggle-icon match-slider__toggle-icon--pause') ?>
<?= icon('play-fill', 'match-slider__toggle-icon match-slider__toggle-icon--play') ?>
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
</button>
<div class="match-slider__dots" role="group" aria-label="Spiel wählen">
<?php foreach ($matches as $i => $m): ?>
<button class="match-slider__dot" type="button" data-carousel-dot aria-label="Spiel <?= ($i + 1) ?> von <?= $total ?>: <?= e(($m['home'] ?? '') . ' gegen ' . ($m['away'] ?? '')) ?>">
<span class="match-slider__progress" data-carousel-progress aria-hidden="true"></span>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* „Nächste Spiele"-Band (teamübergreifend): abgesetzte dunklere Fläche mit roter
* Oberkante, links der Aufmacher (das zeitlich nächste Spiel aller Mannschaften),
* rechts die folgenden Termine als kompakte, anklickbare Zeilen. Props:
* $matches data/matchcenter.json → upcoming[] (chronologisch)
* $teams data/matchcenter.json → teams[] (liefert Liganame + Sprungmarke)
* Leer → nichts rendern.
*/
$matches = $matches ?? [];
$teams = $teams ?? [];
if ($matches === []) {
return;
}
// Liganame und Sprungmarke je Mannschaft vorbereiten, damit die Karten nicht
// selbst in den Teams suchen müssen. Anker wie in matchcenter-section.php.
$leagues = [];
$anchors = [];
foreach ($teams as $t) {
$key = (string) ($t['key'] ?? '');
if ($key === '') {
continue;
}
$leagues[$key] = (string) ($t['league'] ?? '');
$anchors[$key] = '#team-' . preg_replace('/[^a-z0-9-]+/', '', strtolower($key));
}
$feature = array_shift($matches);
$featureKey = (string) ($feature['team_key'] ?? '');
?>
<section class="section section--sunken next-matches" id="naechste-spiele">
<div class="container">
<h2 class="next-matches__title">Nächste Spiele</h2>
<div class="next-matches__grid">
<?php component('match-feature', [
'match' => $feature,
'team_name' => $feature['team_name'] ?? '',
'league' => $leagues[$featureKey] ?? '',
'href' => $anchors[$featureKey] ?? null,
]); ?>
<?php if ($matches !== []): ?>
<div class="next-matches__list">
<h3 class="next-matches__list-title">Danach</h3>
<ul class="match-list match-list--plain">
<?php foreach ($matches as $m): ?>
<?php component('match-row', [
'match' => $m,
'meta' => $m['team_name'] ?? '',
'href' => $anchors[(string) ($m['team_key'] ?? '')] ?? null,
]); ?>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
</div>
</section>

View File

@@ -125,6 +125,61 @@ function img_intrinsic_attrs(string $rel): string
return $cache[$rel];
}
/**
* Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus
* data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin —
* PHP läuft hier global auf UTC, deshalb explizit. Liefert:
* iso maschinenlesbar mit Zone (für <time datetime> und den JS-Ticker)
* countdown „heute, 14:00 Uhr" | „morgen, 14:00 Uhr" | „in 6 Tagen" | „in 3 Wochen"
* Unlesbarer Wert → beide Felder leer; bereits angestoßenes Spiel → countdown leer.
*/
function match_when(string $kickoff): array
{
if ($kickoff === '') {
return ['iso' => '', 'countdown' => ''];
}
$tz = new DateTimeZone('Europe/Berlin');
try {
$start = new DateTimeImmutable($kickoff, $tz);
} catch (Exception) {
return ['iso' => '', 'countdown' => ''];
}
$now = new DateTimeImmutable('now', $tz);
if ($start <= $now) {
return ['iso' => $start->format('c'), 'countdown' => ''];
}
// Kalendertage zählen, nicht 24-Stunden-Blöcke: ein Anstoß morgen um 14 Uhr
// ist „morgen", auch wenn es nur 20 Stunden hin sind.
$days = (int) $now->setTime(0, 0)->diff($start->setTime(0, 0))->format('%a');
$time = $start->format('H:i') . ' Uhr';
return [
'iso' => $start->format('c'),
'countdown' => match (true) {
$days === 0 => 'heute, ' . $time,
$days === 1 => 'morgen, ' . $time,
$days < 14 => 'in ' . $days . ' Tagen',
default => 'in ' . (int) round($days / 7) . ' Wochen',
},
];
}
/**
* Anzeigename eines Wettbewerbs aus data/matchcenter.json. Die BFV-API kennt nur
* „Liga" und „Freundschaft": „Liga" wird durch den echten Liganamen ersetzt — ohne
* $league bleibt sie leer, damit Ligaspiele in den Mannschafts-Sektionen nicht
* redundant beschriftet werden (die Sektion nennt die Liga schon). „Freundschaft"
* wird ausgeschrieben, damit erkennbar ist, warum ein Testspiel nicht in der
* Tabelle zählt.
*/
function match_competition_label(string $competition, string $league = ''): string
{
return match ($competition) {
'Liga' => $league,
'Freundschaft' => 'Freundschaftsspiel',
default => $competition,
};
}
/**
* Komponente rendern: component('hero', ['title' => …]).
* Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
/**
* Matchcenter: Spielplan, Ergebnisse und Tabellen der Aktivenmannschaften.
* Daten: data/matchcenter.json (maschinenverwaltet von bin/matchcenter-sync.php).
* Struktur: Hero → „Nächste Spiele"-Slider → pro Mannschaft eine Sektion
* Struktur: Hero → „Nächste Spiele"-Band → pro Mannschaft eine Sektion
* (Nächste Spiele · Letzte Ergebnisse · Tabelle). Ohne Cache rendert die Seite
* einen freundlichen Hinweis statt eines Fehlers (Graceful Degradation).
*/
@@ -17,7 +17,7 @@ $meta = [
'title' => 'Matchcenter Spiele, Ergebnisse & Tabellen',
'description' => 'Spielplan, Ergebnisse und Tabellen der Mannschaften des TSV 08 Kulmbach 1. und 2. Mannschaft sowie Damen, automatisch aktuell.',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'scripts' => ['carousel.js', 'crest.js', 'group-tabs.js'],
'scripts' => ['crest.js', 'group-tabs.js'],
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
@@ -45,7 +45,7 @@ if ($teams === []) {
return;
}
component('match-slider', ['matches' => $upcoming]);
component('next-matches', ['matches' => $upcoming, 'teams' => $teams]);
foreach ($teams as $team) {
component('matchcenter-section', ['team' => $team]);