This commit is contained in:
2026-06-30 11:13:19 -05:00
parent 63108cb5a1
commit 2a57be6e2f
4 changed files with 96 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { fetchMatchesAndTeams, fetchOdds, attachOdds, attachKOOdds, fetchFifaScores, applyFifaScores, assignKONumbersBySlots } from "@/lib/feeds";
import { fetchMatchesAndTeams, fetchOdds, attachOdds, attachKOOdds, fetchFifaScores, applyFifaScores, assignKONumbersBySlots, attachFifaGoals } from "@/lib/feeds";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
@@ -32,6 +32,11 @@ export async function GET() {
try {
const fifaData = await fetchFifaScores();
matches = applyFifaScores(matches, teams, fifaData);
try {
matches = await attachFifaGoals(matches, fifaData);
} catch (err) {
console.warn("[fifa] Goals fehlgeschlagen:", err instanceof Error ? err.message : err);
}
} catch (err) {
console.warn("[fifa] Scores fehlgeschlagen:", err instanceof Error ? err.message : err);
}

View File

@@ -198,10 +198,9 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches:
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{m.goals.map((g, i) => (
<span key={i} style={{
display: "inline-flex", alignItems: "center", gap: 3,
color: g.team === "home" ? "var(--ink)" : "var(--ink)",
display: "inline-flex", alignItems: "center", gap: 4,
}}>
{g.team === "home" ? "⬆" : "⬇"}
<Flag team={g.team === "home" ? home : away} size={14} />
<span style={{ color: "var(--turf)", fontWeight: 700 }}>
{g.scorer}
</span>

View File

@@ -1,4 +1,4 @@
import { GroupId, Match, MatchStatus, Team } from "./types";
import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
import { venueFor } from "./venues";
import { localisedTeamName } from "./team-mappings";
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
@@ -578,6 +578,8 @@ interface FifaTeamBlock {
interface FifaMatch {
MatchNumber: number;
IdMatch?: string | null;
IdStage?: string | null;
Home: FifaTeamBlock | null;
Away: FifaTeamBlock | null;
HomeTeamScore: number | null;
@@ -599,6 +601,8 @@ interface FifaScores {
status: MatchStatus;
matchTime: string | null;
winnerTeamId: string | null;
idMatch: string | null;
idStage: string | null;
}
// Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
@@ -641,6 +645,8 @@ export async function fetchFifaScores(): Promise<{
status,
matchTime: fm.MatchTime,
winnerTeamId: fm.Winner ?? null,
idMatch: fm.IdMatch ?? null,
idStage: fm.IdStage ?? null,
});
}
return { scores, fifaIdToAppCode };
@@ -692,4 +698,84 @@ export function applyFifaScores(
});
if (applied > 0) console.log("[fifa] scores angewandt:", applied);
return result;
}
// Holt Tor-Details pro Spiel vom FIFA-Detail-Endpoint.
interface FifaGoalRaw { scorer: string; minute: string; team: "home" | "away"; type: number | null; }
function locName(arr: Array<{ Locale: string; Description: string }> | undefined): string {
if (!arr || arr.length === 0) return "";
return (arr.find(x => x.Locale === "de-DE")
?? arr.find(x => x.Locale === "en-GB")
?? arr[0])?.Description ?? "";
}
function normMinuteStr(min: string | null | undefined): string {
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
}
async function fetchFifaGoals(idStage: string, idMatch: string): Promise<FifaGoalRaw[]> {
return cached(`fifa:detail:${idMatch}`, 60_000, async () => {
const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=de`;
const res = await fetch(url, {
cache: "no-store",
headers: { "User-Agent": "wm2026-board/1.0" },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) { console.warn("[fifa-detail] status", res.status, idMatch); return []; }
const dj: any = await res.json();
const buildPlayerMap = (teamBlock: any): Map<string, string> => {
const map = new Map<string, string>();
for (const p of teamBlock?.Players ?? []) {
map.set(String(p.IdPlayer), locName(p.PlayerName) || locName(p.ShortName));
}
return map;
};
const homeBlock = dj.HomeTeam, awayBlock = dj.AwayTeam;
const homePlayers = buildPlayerMap(homeBlock);
const awayPlayers = buildPlayerMap(awayBlock);
const goals: FifaGoalRaw[] = [];
for (const [block, players, side] of [
[homeBlock, homePlayers, "home"] as const,
[awayBlock, awayPlayers, "away"] as const,
]) {
for (const g of block?.Goals ?? []) {
const min = normMinuteStr(g.Minute);
if (min === "" || isNaN(parseInt(min, 10))) continue;
goals.push({
scorer: players.get(String(g.IdPlayer)) || "?",
minute: normMinuteStr(g.Minute),
team: side,
type: g.Type ?? null,
});
}
}
goals.sort((a, b) => parseInt(a.minute, 10) - parseInt(b.minute, 10));
return goals;
});
}
// Lädt Tor-Details für beendete/laufende Spiele mit Toren und hängt sie an die Matches an.
export async function attachFifaGoals(
matches: Match[],
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
): Promise<Match[]> {
const targets = matches.filter(m =>
(m.status === "FINISHED" || m.status === "LIVE" || m.status === "IN_PLAY") &&
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
);
const goalsByMatchId = new Map<string, GoalEvent[]>();
await Promise.all(targets.map(async (m) => {
const fs = fifaData.scores.get(m.matchNumber);
if (!fs?.idMatch || !fs?.idStage) return;
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch);
if (goals.length) goalsByMatchId.set(m.id, goals);
}));
if (goalsByMatchId.size === 0) return matches;
return matches.map(m => {
const g = goalsByMatchId.get(m.id);
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
});
}

View File

@@ -23,7 +23,7 @@ export type MatchStatus =
export interface GoalEvent {
scorer: string; // Torschützen-Name
minute: number; // Spielminute
minute: string; // Spielminute (z.B. "72", "90+1")
team: "home" | "away";
}