Matchcenter: Saison-Tabs, Vorsaison-Archiv & gehärteter Cache-Write

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 00:51:14 +02:00
parent 9722d8eccc
commit 3a591fe8d0
5 changed files with 206 additions and 43 deletions

View File

@@ -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 JulJun): 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<string,mixed> $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/');