From 3a591fe8d0e1ba0d98e8e51c74947daf7dd5aa4a Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 21 Jun 2026 00:51:14 +0200 Subject: [PATCH] =?UTF-8?q?Matchcenter:=20Saison-Tabs,=20Vorsaison-Archiv?= =?UTF-8?q?=20&=20geh=C3=A4rteter=20Cache-Write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync schreibt Cache jetzt fehlersicher: json_encode mit JSON_THROW_ON_ERROR + Rückgabeprüfung von file_put_contents/rename → ein defektes UTF-8-Zeichen aus der BFV-API kann den guten Cache nicht mehr leerschreiben (alter Cache bleibt via $fail unangetastet) - Vorsaison wird bei erkanntem compoundId-Wechsel einmalig archiviert (previous), pro Team compound_id + abgeleitetes season-Label + stale-Flag persistiert - Aktuelle Saison ist immer sichtbar (auch leer), Wechsel zur Vorsaison über wiederverwendete Stripe-Tabbar (group-tabs); neue Komponente matchcenter-season - Leere aktuelle Saison zeigt Hinweis statt leerer Fläche Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/matchcenter-season.php | 54 ++++++++++++++ app/components/matchcenter-section.php | 76 ++++++++++++-------- app/pages/matchcenter.php | 2 +- bin/matchcenter-sync.php | 98 ++++++++++++++++++++++---- public/assets/css/components.css | 19 +++++ 5 files changed, 206 insertions(+), 43 deletions(-) create mode 100644 app/components/matchcenter-season.php diff --git a/app/components/matchcenter-season.php b/app/components/matchcenter-season.php new file mode 100644 index 0000000..3b19692 --- /dev/null +++ b/app/components/matchcenter-season.php @@ -0,0 +1,54 @@ + +
+ +
+ labelt die Tabelle für Screenreader. */ ?> + $table, 'caption' => $caption]); ?> +
+ + + + + +

Spiele folgen, sobald die Saison startet.

+ +
diff --git a/app/components/matchcenter-section.php b/app/components/matchcenter-section.php index b831962..24f223e 100644 --- a/app/components/matchcenter-section.php +++ b/app/components/matchcenter-section.php @@ -4,22 +4,31 @@ declare(strict_types=1); /** * Matchcenter-Sektion einer Mannschaft (kicker-Stil): Kopf (Name + Liga + Link zur - * Mannschaftsseite), darunter zweispaltig — Tabelle 2/3 links, anstehende Spiele + - * letzte Ergebnisse als kompakte Liste 1/3 rechts. Auf schmalen Screens gestapelt. - * Props: $team (data/matchcenter.json → teams[]). Leere Blöcke verstecken sich selbst. + * Mannschaftsseite), darunter die aktuelle Saison (Tabelle + Spiele) via + * matchcenter-season. Liegt eine archivierte Vorsaison vor, sind beide Saisons über + * eine Stripe-Tabbar umschaltbar (wiederverwendetes group-tabs-Pattern, ohne JS + * gestapelt sichtbar). Props: $team (data/matchcenter.json → teams[]). */ $team = $team ?? []; $next = $team['next_matches'] ?? []; $results = $team['last_results'] ?? []; $table = $team['table'] ?? []; $league = $team['league'] ?? null; +$name = (string) ($team['name'] ?? ''); +$season = (string) ($team['season'] ?? ''); +$previous = $team['previous'] ?? null; +$hasPrevious = is_array($previous) + && (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []); $key = preg_replace('/[^a-z0-9-]+/', '', strtolower((string) ($team['key'] ?? ''))); + +$curCaption = 'Tabelle ' . $name . ($season !== '' ? ' · ' . $season : ($league ? ' · ' . $league : '')); +$tabCur = $season !== '' ? $season : 'Aktuelle Saison'; ?>
-

+

@@ -28,33 +37,40 @@ $key = preg_replace('/[^a-z0-9-]+/', '', strtolower((string) ($team['key'] ?? '' Zur Mannschaft
-
- -
- labelt die Tabelle für Screenreader. */ ?> - $table, 'caption' => 'Tabelle ' . ($team['name'] ?? '') . ($league ? ' · ' . $league : '')]); ?> + + $next, 'results' => $results, 'table' => $table, 'caption' => $curCaption, + ]); ?> + + +
+
+ + +
+
+ $next, 'results' => $results, 'table' => $table, 'caption' => $curCaption, + ]); ?> +
+
+ $previous['last_results'] ?? [], 'table' => $previous['table'] ?? [], + 'caption' => $prevCaption, 'is_previous' => true, + ]); ?>
- - - -
+
diff --git a/app/pages/matchcenter.php b/app/pages/matchcenter.php index f4ac3eb..ac29a1f 100644 --- a/app/pages/matchcenter.php +++ b/app/pages/matchcenter.php @@ -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'], + 'scripts' => ['carousel.js', 'crest.js', 'group-tabs.js'], 'schema' => array_merge( page_schema([ ['name' => 'Startseite', 'slug' => ''], diff --git a/bin/matchcenter-sync.php b/bin/matchcenter-sync.php index c5c0fe1..6eb0d73 100644 --- a/bin/matchcenter-sync.php +++ b/bin/matchcenter-sync.php @@ -178,6 +178,29 @@ $dateDisplay = static function (?DateTimeImmutable $dt, string $raw) use ($weekd return $weekdaysDe[(int) $dt->format('w')] . ', ' . $dt->format('d.m.'); }; +// --- Saison-Label (Fußball-Saison läuft Jul–Jun): aus dem jüngsten Spiel ableiten, +// ersatzweise (leere Saison) aus dem heutigen Datum → liefert immer ein Label. --- +$seasonLabel = static function (DateTimeImmutable $d): string { + $y = (int) $d->format('Y'); + $start = (int) $d->format('n') >= 7 ? $y : $y - 1; + return sprintf('Saison %d/%02d', $start, ($start + 1) % 100); +}; +$seasonFromRows = static function (array ...$rowSets) use ($seasonLabel, $tz, $today): string { + $latest = null; + foreach ($rowSets as $rows) { + foreach ($rows as $r) { + if (empty($r['kickoff'])) { + continue; + } + $dt = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $r['kickoff'], $tz); + if ($dt && ($latest === null || $dt > $latest)) { + $latest = $dt; + } + } + } + return $seasonLabel($latest ?? $today); +}; + /** * Ein Match aus der API in unser Anzeige-Format überführen. * @param array $m Rohes Match aus data.matches[] @@ -260,12 +283,32 @@ foreach ($teamsCfg as $cfg) { $prevTeam = $prevByKey[$key] ?? null; try { + $prevCompound = (string) ($prevTeam['compound_id'] ?? ''); + $previous = $prevTeam['previous'] ?? null; + // Spiele zuerst — daraus leiten wir die aktuelle Wettbewerbs-ID (Tabelle) ab, // damit eine neue Saison automatisch übernommen wird (die team_id ist stabil, // die compound_id der Tabelle wechselt pro Saison). $matchesData = $fetchJson("{$apiHost}/api/service/widget/v1/team/{$teamCfgId}/matches"); $apiCompound = (string) ($matchesData['data']['team']['compoundId'] ?? ''); - $compoundId = $apiCompound !== '' ? $apiCompound : $cfgCompoundId; + + // Saisonwechsel: liefert die API eine NEUE compoundId, archivieren wir die eben + // abgelaufene Saison einmalig (verschachteltes previous des Vorgängers wird nicht + // mitkopiert → keine unbegrenzte Schachtelung). + if ($apiCompound !== '' && $prevCompound !== '' && $apiCompound !== $prevCompound && $prevTeam !== null) { + $previous = [ + 'season' => $prevTeam['season'] ?? null, + 'league' => $prevTeam['league'] ?? null, + 'last_results' => $prevTeam['last_results'] ?? [], + 'table' => $prevTeam['table'] ?? [], + ]; + } + + // Beste bekannte compoundId: API > letzter Cache > Config-Fallback. So triggert ein + // vorübergehender Fetch-Ausfall (apiCompound leer) keinen falschen Saisonwechsel. + $compoundId = $apiCompound !== '' + ? $apiCompound + : ($prevCompound !== '' ? $prevCompound : $cfgCompoundId); $tableData = $compoundId !== '' ? $fetchJson("{$apiHost}/api/service/widget/v1/competition/{$compoundId}/table") : null; // Name/Liga bevorzugt aus teams.json (Single Source), sonst API/alter Cache. @@ -366,26 +409,43 @@ foreach ($teamsCfg as $cfg) { $log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen"); } - if ($upcoming === [] && $results === [] && $table === []) { + // Aktuelle Saison immer zeigen, solange der Spiele-Fetch erfolgreich war (auch wenn + // sie noch leer ist) oder Daten/eine Vorsaison vorliegen. Nur überspringen, wenn der + // Fetch fehlschlug UND weder Cache noch Vorsaison existieren. + $hasCurrent = $upcoming !== [] || $results !== [] || $table !== []; + $hasPrevious = $previous !== null + && (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []); + if (!$hasCurrent && $matchesData === null && !$hasPrevious) { $log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen"); continue; } - $resultTeams[] = [ + $season = $seasonFromRows($upcoming, $results); + + $teamBlock = [ 'key' => $key, 'name' => $name, 'league' => $league, 'slug' => $slugMap[$key] ?? '', + 'compound_id' => $compoundId, + 'season' => $season, 'next_matches' => $upcoming, 'last_results' => $results, 'table' => $table, ]; + if ($hasPrevious) { + $teamBlock['previous'] = $previous; + $keepCrests($previous['last_results'] ?? []); + $keepCrests($previous['table'] ?? []); + } + $resultTeams[] = $teamBlock; foreach ($upcoming as $u) { $upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $name]); } - $log("Team {$key}: " . count($upcoming) . ' anstehend, ' . count($results) . ' Ergebnisse, ' . count($table) . ' Tabellenplätze'); + $log("Team {$key}: " . count($upcoming) . ' anstehend, ' . count($results) . ' Ergebnisse, ' . count($table) . ' Tabellenplätze' + . ($hasPrevious ? ' (+ Vorsaison archiviert)' : '')); } catch (\Throwable $e) { // Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten. $log("Team {$key} übersprungen: " . $e->getMessage()); @@ -395,6 +455,8 @@ foreach ($teamsCfg as $cfg) { $keepCrests($prevTeam['next_matches'] ?? []); $keepCrests($prevTeam['last_results'] ?? []); $keepCrests($prevTeam['table'] ?? []); + $keepCrests($prevTeam['previous']['last_results'] ?? []); + $keepCrests($prevTeam['previous']['table'] ?? []); foreach ($prevTeam['next_matches'] ?? [] as $u) { $upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $prevTeam['name'] ?? $key]); } @@ -421,15 +483,27 @@ if (!$cacheFallbackUsed && $crestKeep !== []) { } } -// --- Cache atomar schreiben (tmp + rename) --- -$payload = json_encode([ - 'fetched_at' => time(), - 'teams' => $resultTeams, - 'upcoming' => $upcomingAll, -], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +// --- Cache atomar schreiben (tmp + rename). Jeder Schritt ist abgesichert: ein +// defektes UTF-8-Zeichen aus der API (json_encode → Exception) oder ein +// Schreib-/Rename-Fehler darf den bestehenden guten Cache NICHT leerschreiben. --- +try { + $payload = json_encode([ + 'fetched_at' => time(), + 'stale' => $cacheFallbackUsed, // true = mind. ein Teil kam aus dem alten Cache + 'teams' => $resultTeams, + 'upcoming' => $upcomingAll, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); +} catch (\Throwable $e) { + $fail('Cache-Encode fehlgeschlagen: ' . $e->getMessage()); +} $tmp = $cacheFile . '.tmp'; -file_put_contents($tmp, $payload); -rename($tmp, $cacheFile); +if (file_put_contents($tmp, $payload) === false) { + $fail("Cache-Schreiben fehlgeschlagen (tmp: {$tmp})"); +} +if (!rename($tmp, $cacheFile)) { + @unlink($tmp); + $fail("Cache-Rename fehlgeschlagen ({$tmp} → {$cacheFile})"); +} $log('OK: ' . count($resultTeams) . ' Teams gecached, ' . count($crestKeep) . ' Wappen lokal in assets/img/crests/'); diff --git a/public/assets/css/components.css b/public/assets/css/components.css index 60e8099..9e504bf 100644 --- a/public/assets/css/components.css +++ b/public/assets/css/components.css @@ -3905,6 +3905,25 @@ color: var(--clr-text-muted); } +/* Saison-Umschalter: wiederverwendete Stripe-Tabbar (group-tabs__bar/__tab), aber + linksbündig und etwas kompakter als bei den Gruppen-Tabs. Ohne JS sind beide + Panels gestapelt sichtbar (group-tabs.js versteckt erst on init das zweite). */ +.matchcenter-team__seasons-bar { + justify-content: flex-start; + margin-bottom: 1.25rem; +} + +/* display-neutrales Panel (kein group-tabs__panel-Grid) — das innere + matchcenter-team__grid bringt sein eigenes Layout mit. */ +.matchcenter-team__season-panel[hidden] { + display: none; +} + +.matchcenter-team__empty { + margin: 0; + color: var(--clr-text-muted); +} + /* kicker-Layout: Tabelle 2/3 links, Spiele 1/3 rechts; gestapelt auf Mobil. align-items:start + 0-Margins der Karten → beide Spalten starten oben bündig. */ .matchcenter-team__grid {