added ranking and fetching sync
dongho-repo/worldcup2026/pipeline/head This commit looks good

This commit is contained in:
Dongho Kim
2026-06-15 16:51:24 +02:00
parent d5c9765970
commit 3938bfae5b
5 changed files with 1052 additions and 89 deletions
+196
View File
@@ -0,0 +1,196 @@
{
"scorers": [
{
"groupId": "A",
"matchday": 1,
"home": "Mexico",
"away": "South Africa",
"scorers": {
"home": [
{ "name": "Julián Quiñones", "min": 9 },
{ "name": "Raúl Jiménez", "min": 67 }
],
"away": []
}
},
{
"groupId": "A",
"matchday": 1,
"home": "South Korea",
"away": "Czech Republic",
"scorers": {
"home": [
{ "name": "Hwang In-beom", "min": 67 },
{ "name": "Oh Hyeon-gyu", "min": 80 }
],
"away": [
{ "name": "Ladislav Krejčí", "min": 59 }
]
}
},
{
"groupId": "B",
"matchday": 1,
"home": "Canada",
"away": "Bosnia & Herzegovina",
"scorers": {
"home": [
{ "name": "Cyle Larin", "min": 78 }
],
"away": [
{ "name": "Jovo Lukić", "min": 21 }
]
}
},
{
"groupId": "B",
"matchday": 1,
"home": "Qatar",
"away": "Switzerland",
"scorers": {
"home": [
{ "name": "Boualem Khoukhi", "min": 94 }
],
"away": [
{ "name": "Breel Embolo", "min": 17, "type": "pen" }
]
}
},
{
"groupId": "C",
"matchday": 1,
"home": "Brazil",
"away": "Morocco",
"scorers": {
"home": [
{ "name": "Vinícius Júnior", "min": 32 }
],
"away": [
{ "name": "Ismael Saibari", "min": 21 }
]
}
},
{
"groupId": "C",
"matchday": 1,
"home": "Haiti",
"away": "Scotland",
"scorers": {
"home": [],
"away": [
{ "name": "John McGinn", "min": 28 }
]
}
},
{
"groupId": "D",
"matchday": 1,
"home": "United States",
"away": "Paraguay",
"scorers": {
"home": [
{ "name": "Damián Bobadilla (OG)", "min": 7 },
{ "name": "Folarin Balogun", "min": 31 },
{ "name": "Folarin Balogun", "min": 45 },
{ "name": "Gio Reyna", "min": 98 }
],
"away": [
{ "name": "Maurício", "min": 73 }
]
}
},
{
"groupId": "D",
"matchday": 1,
"home": "Australia",
"away": "Türkiye",
"scorers": {
"home": [
{ "name": "Nestory Irankunda", "min": 27 },
{ "name": "Connor Metcalfe", "min": 75 }
],
"away": []
}
},
{
"groupId": "E",
"matchday": 1,
"home": "Germany",
"away": "Curaçao",
"scorers": {
"home": [
{ "name": "Felix Nmecha", "min": 6 },
{ "name": "Nico Schlotterbeck", "min": 38 },
{ "name": "Kai Havertz", "min": 45, "type": "pen" },
{ "name": "Jamal Musiala", "min": 47 },
{ "name": "Nathaniel Brown", "min": 68 },
{ "name": "Deniz Undav", "min": 78 },
{ "name": "Kai Havertz", "min": 88 }
],
"away": [
{ "name": "Livano Comenencia", "min": 21 }
]
}
},
{
"groupId": "E",
"matchday": 1,
"home": "Côte d'Ivoire",
"away": "Ecuador",
"scorers": {
"home": [
{ "name": "Amad Diallo", "min": 90 }
],
"away": []
}
},
{
"groupId": "F",
"matchday": 1,
"home": "Netherlands",
"away": "Japan",
"scorers": {
"home": [
{ "name": "Virgil van Dijk", "min": 51 },
{ "name": "Crysencio Summerville", "min": 64 }
],
"away": [
{ "name": "Keito Nakamura", "min": 57 },
{ "name": "Daichi Kamada", "min": 88 }
]
}
},
{
"groupId": "F",
"matchday": 1,
"home": "Sweden",
"away": "Tunisia",
"scorers": {
"home": [
{ "name": "Yasin Ayari", "min": 7 },
{ "name": "Alexander Isak", "min": 30 },
{ "name": "Viktor Gyökeres", "min": 59 },
{ "name": "Mattias Svanberg", "min": 84 },
{ "name": "Yasin Ayari", "min": 96 }
],
"away": [
{ "name": "Omar Rekik", "min": 43 }
]
}
},
{
"groupId": "H",
"matchday": 1,
"home": "Spain",
"away": "Cape Verde",
"scorers": {
"home": [
{ "name": "Álvaro Morata", "min": 15 },
{ "name": "Dani Olmo", "min": 55 },
{ "name": "Nico Williams", "min": 82 }
],
"away": []
}
}
]
}
+95
View File
@@ -0,0 +1,95 @@
{
"matches": [
{
"groupId": "A",
"matchday": 1,
"home": "Mexico",
"away": "South Africa",
"score": { "home": 2, "away": 0 }
},
{
"groupId": "A",
"matchday": 1,
"home": "South Korea",
"away": "Czech Republic",
"score": { "home": 2, "away": 1 }
},
{
"groupId": "B",
"matchday": 1,
"home": "Canada",
"away": "Bosnia & Herzegovina",
"score": { "home": 1, "away": 1 }
},
{
"groupId": "B",
"matchday": 1,
"home": "Qatar",
"away": "Switzerland",
"score": { "home": 1, "away": 1 }
},
{
"groupId": "C",
"matchday": 1,
"home": "Brazil",
"away": "Morocco",
"score": { "home": 1, "away": 1 }
},
{
"groupId": "C",
"matchday": 1,
"home": "Haiti",
"away": "Scotland",
"score": { "home": 0, "away": 1 }
},
{
"groupId": "D",
"matchday": 1,
"home": "United States",
"away": "Paraguay",
"score": { "home": 4, "away": 1 }
},
{
"groupId": "D",
"matchday": 1,
"home": "Australia",
"away": "Türkiye",
"score": { "home": 2, "away": 0 }
},
{
"groupId": "E",
"matchday": 1,
"home": "Germany",
"away": "Curaçao",
"score": { "home": 7, "away": 1 }
},
{
"groupId": "E",
"matchday": 1,
"home": "Côte d'Ivoire",
"away": "Ecuador",
"score": { "home": 1, "away": 0 }
},
{
"groupId": "F",
"matchday": 1,
"home": "Netherlands",
"away": "Japan",
"score": { "home": 2, "away": 2 }
},
{
"groupId": "F",
"matchday": 1,
"home": "Sweden",
"away": "Tunisia",
"score": { "home": 5, "away": 1 }
},
{
"groupId": "H",
"matchday": 1,
"home": "Spain",
"away": "Cape Verde",
"score": { "home": 3, "away": 0 }
}
]
}
+2 -2
View File
@@ -159,7 +159,7 @@ export const groups = [
], ],
matches: [ matches: [
{ matchday: 1, home: 'Netherlands', away: 'Japan', date: 'Sun, Jun 14', time: '22:00', venue: 'Los Angeles' }, { matchday: 1, home: 'Netherlands', away: 'Japan', date: 'Sun, Jun 14', time: '22:00', venue: 'Los Angeles' },
{ matchday: 1, home: 'Sweden', away: 'Tunisia', date: 'Mon, Jun 15', time: '16:00', venue: 'Boston' }, { matchday: 1, home: 'Sweden', away: 'Tunisia', date: 'Mon, Jun 15', time: '06:00', venue: 'Boston' },
{ matchday: 2, home: 'Netherlands', away: 'Sweden', date: 'Sat, Jun 20', time: '19:00', venue: 'New York' }, { matchday: 2, home: 'Netherlands', away: 'Sweden', date: 'Sat, Jun 20', time: '19:00', venue: 'New York' },
{ matchday: 2, home: 'Japan', away: 'Tunisia', date: 'Sat, Jun 20', time: '22:00', venue: 'Kansas City' }, { matchday: 2, home: 'Japan', away: 'Tunisia', date: 'Sat, Jun 20', time: '22:00', venue: 'Kansas City' },
{ matchday: 3, home: 'Netherlands', away: 'Tunisia', date: 'Fri, Jun 26', time: '18:00', venue: 'Dallas' }, { matchday: 3, home: 'Netherlands', away: 'Tunisia', date: 'Fri, Jun 26', time: '18:00', venue: 'Dallas' },
@@ -194,7 +194,7 @@ export const groups = [
{ name: 'Uruguay', flag: '🇺🇾', confederation: 'CONMEBOL', isHost: false }, { name: 'Uruguay', flag: '🇺🇾', confederation: 'CONMEBOL', isHost: false },
], ],
matches: [ matches: [
{ matchday: 1, home: 'Spain', away: 'Cape Verde', date: 'Mon, Jun 15', time: '16:00', venue: 'Guadalajara' }, { matchday: 1, home: 'Spain', away: 'Cape Verde', date: 'Mon, Jun 15', time: '08:00', venue: 'Guadalajara' },
{ matchday: 1, home: 'Saudi Arabia', away: 'Uruguay', date: 'Tue, Jun 16', time: '16:00', venue: 'Mexico City' }, { matchday: 1, home: 'Saudi Arabia', away: 'Uruguay', date: 'Tue, Jun 16', time: '16:00', venue: 'Mexico City' },
{ matchday: 2, home: 'Spain', away: 'Uruguay', date: 'Sat, Jun 21', time: '19:00', venue: 'Monterrey' }, { matchday: 2, home: 'Spain', away: 'Uruguay', date: 'Sat, Jun 21', time: '19:00', venue: 'Monterrey' },
{ matchday: 2, home: 'Cape Verde', away: 'Saudi Arabia', date: 'Sat, Jun 21', time: '22:00', venue: 'Guadalajara' }, { matchday: 2, home: 'Cape Verde', away: 'Saudi Arabia', date: 'Sat, Jun 21', time: '22:00', venue: 'Guadalajara' },
+586 -69
View File
@@ -5,28 +5,227 @@ import { getSquad } from './squads.js';
console.log('[WC2026] Loaded groups:', groups.length, '| First group matches:', groups[0]?.matches?.length); console.log('[WC2026] Loaded groups:', groups.length, '| First group matches:', groups[0]?.matches?.length);
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
let activeView = 'groups';
let activeFilter = 'all'; let activeFilter = 'all';
let searchQuery = ''; let searchQuery = '';
let fetchedScores = [];
let fetchedScorers = [];
let userSimulations = {};
let appGroups = []; let appGroups = [];
function loadState() { function getMatchKey(groupId, matchday, home, away) {
const saved = localStorage.getItem('wc2026_groups'); return `${groupId}-${matchday}-${home}-${away}`;
}
function resetMatchToBaseState(groupId, matchday, home, away) {
const baseGroup = groups.find(g => g.id === groupId);
if (!baseGroup) return;
const baseMatch = baseGroup.matches.find(m => m.matchday === matchday && m.home === home && m.away === away);
if (!baseMatch) return;
const group = appGroups.find(g => g.id === groupId);
if (!group) return;
const matchIdx = group.matches.findIndex(m => m.matchday === matchday && m.home === home && m.away === away);
if (matchIdx !== -1) {
group.matches[matchIdx] = JSON.parse(JSON.stringify(baseMatch));
}
}
function loadUserSimulations() {
const saved = localStorage.getItem('wc2026_user_sims');
if (saved) { if (saved) {
try { try {
appGroups = JSON.parse(saved); userSimulations = JSON.parse(saved);
return; return;
} catch (e) { } catch (e) {
console.error('[WC2026] Failed to load saved state:', e); console.error('[WC2026] Failed to load user simulations:', e);
} }
} }
appGroups = JSON.parse(JSON.stringify(groups)); userSimulations = {};
} }
function saveState() { function saveUserSimulations() {
localStorage.setItem('wc2026_groups', JSON.stringify(appGroups)); localStorage.setItem('wc2026_user_sims', JSON.stringify(userSimulations));
} }
loadState(); async function fetchScores() {
try {
const res = await fetch('/api/scores.json');
if (res.ok) {
const data = await res.json();
fetchedScores = data.matches || [];
console.log('[WC2026] Fetched baseline scores:', fetchedScores.length);
} else {
console.error('[WC2026] Failed to fetch baseline scores:', res.status);
}
} catch (e) {
console.error('[WC2026] Error fetching baseline scores:', e);
}
}
async function fetchScorers() {
try {
const res = await fetch('/api/scorers.json');
if (res.ok) {
const data = await res.json();
fetchedScorers = data.scorers || [];
console.log('[WC2026] Fetched baseline scorers:', fetchedScorers.length);
} else {
console.error('[WC2026] Failed to fetch baseline scorers:', res.status);
}
} catch (e) {
console.error('[WC2026] Error fetching baseline scorers:', e);
}
}
function formatFetchedScorers(scorers) {
const formatted = { home: [], away: [] };
if (scorers.home) {
formatted.home = scorers.home.map(s => ({
name: s.name,
min: `${s.min}'`
}));
}
if (scorers.away) {
formatted.away = scorers.away.map(s => ({
name: s.name,
min: `${s.min}'`
}));
}
return formatted;
}
function updateMatchStates() {
const now = new Date();
appGroups.forEach(group => {
group.matches.forEach(match => {
const matchKey = getMatchKey(group.id, match.matchday, match.home, match.away);
// 1. Check if user has simulated this match
if (userSimulations[matchKey] !== undefined) {
const sim = userSimulations[matchKey];
match.status = 'FT';
match.score = { home: sim.home, away: sim.away };
// Preserve default scorers if user restored Mexico vs South Africa to 2-1
if (group.id === 'A' && match.matchday === 1 && match.home === 'Mexico' && sim.home === 2 && sim.away === 1) {
match.scorers = {
home: [
{ name: 'Santiago Giménez', min: "24'" },
{ name: 'Henry Martín', min: "78'" }
],
away: [
{ name: 'Percy Tau', min: "41' (pen)" }
]
};
} else {
delete match.scorers;
}
return;
}
// 2. If the match is already finished in-memory, skip processing (caching completed games)
if (match.status === 'FT') {
return;
}
// 3. Blend fetched scores & dynamic live clock
const fetched = fetchedScores.find(f => f.groupId === group.id && f.matchday === match.matchday && f.home === match.home && f.away === match.away);
if (!fetched) {
delete match.status;
delete match.score;
delete match.scorers;
return;
}
// Look up scorers from the cached fetchedScorers array
const scorersInfo = fetchedScorers.find(s => s.groupId === group.id && s.matchday === match.matchday && s.home === match.home && s.away === match.away);
const matchScorers = scorersInfo ? scorersInfo.scorers : undefined;
const venue = venues[match.venue];
if (!venue || venue.utcOffset === undefined) {
match.status = 'FT';
match.score = fetched.score;
match.scorers = matchScorers ? formatFetchedScorers(matchScorers) : undefined;
return;
}
const matchResult = match.date.match(/([A-Za-z]+)\s+(\d+)/);
if (!matchResult) {
match.status = 'FT';
match.score = fetched.score;
match.scorers = matchScorers ? formatFetchedScorers(matchScorers) : undefined;
return;
}
const [, monthStr, dayStr] = matchResult;
const [hour, minute] = match.time.split(':');
const monthMap = { 'Jun': 5, 'Jul': 6 };
const utcHours = parseInt(hour, 10) - venue.utcOffset;
const kickOffUTC = new Date(Date.UTC(2026, monthMap[monthStr], parseInt(dayStr, 10), utcHours, parseInt(minute, 10)));
const diffMin = Math.floor((now - kickOffUTC) / 60000);
if (diffMin < 0) {
// Future match
delete match.status;
delete match.score;
delete match.scorers;
} else if (diffMin >= 110) {
// Completed match
match.status = 'FT';
match.score = fetched.score;
match.scorers = matchScorers ? formatFetchedScorers(matchScorers) : undefined;
} else {
// LIVE match in progress!
let currentMinute = diffMin;
let displayStatus = `${currentMinute}'`;
if (diffMin > 45 && diffMin <= 60) {
displayStatus = 'HT';
currentMinute = 45;
} else if (diffMin > 60 && diffMin <= 105) {
displayStatus = `${diffMin - 15}'`;
currentMinute = diffMin - 15;
} else if (diffMin > 105) {
displayStatus = `90+${diffMin - 105}'`;
currentMinute = 90;
}
match.status = displayStatus;
match.isLive = true;
const liveScorers = { home: [], away: [] };
let liveHomeScore = 0;
let liveAwayScore = 0;
if (matchScorers) {
if (matchScorers.home) {
matchScorers.home.forEach(g => {
if (g.min <= currentMinute) {
liveHomeScore++;
liveScorers.home.push({ name: g.name, min: `${g.min}'` });
}
});
}
if (matchScorers.away) {
matchScorers.away.forEach(g => {
if (g.min <= currentMinute) {
liveAwayScore++;
liveScorers.away.push({ name: g.name, min: `${g.min}'` });
}
});
}
}
match.score = { home: liveHomeScore, away: liveAwayScore };
match.scorers = liveScorers;
}
});
});
}
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function confBadge(confederation) { function confBadge(confederation) {
@@ -35,8 +234,10 @@ function confBadge(confederation) {
} }
function calculateStandings(group) { function calculateStandings(group) {
const fullGroup = appGroups.find(g => g.id === group.id) || group;
const standings = {}; const standings = {};
group.teams.forEach(team => {
fullGroup.teams.forEach(team => {
standings[team.name] = { standings[team.name] = {
name: team.name, name: team.name,
flag: team.flag, flag: team.flag,
@@ -53,8 +254,8 @@ function calculateStandings(group) {
}; };
}); });
group.matches.forEach(match => { fullGroup.matches.forEach(match => {
if (match.status === 'FT' && match.score) { if (match.score && match.status && match.status !== 'Scheduled') {
const home = match.home; const home = match.home;
const away = match.away; const away = match.away;
const homeScore = parseInt(match.score.home, 10); const homeScore = parseInt(match.score.home, 10);
@@ -98,7 +299,7 @@ function calculateStandings(group) {
function teamCard(team, rank, pts, played) { function teamCard(team, rank, pts, played) {
const hostBadge = team.isHost ? '<span class="host-badge">🏟️ Host</span>' : ''; const hostBadge = team.isHost ? '<span class="host-badge">🏟️ Host</span>' : '';
const ptsBadge = played > 0 ? `<span class="team-pts-badge">${pts} PTS</span>` : ''; const ptsBadge = `<span class="team-pts-badge">${pts} PTS</span>`;
const rankClass = rank <= 2 ? 'rank-adv' : rank === 3 ? 'rank-pot' : 'rank-el'; const rankClass = rank <= 2 ? 'rank-adv' : rank === 3 ? 'rank-pot' : 'rank-el';
return ` return `
<div class="team-card"> <div class="team-card">
@@ -113,13 +314,19 @@ function teamCard(team, rank, pts, played) {
} }
function groupCard(group) { function groupCard(group) {
const standings = calculateStandings(group); const fullStandings = calculateStandings(group);
const teamsHTML = standings.map((item, idx) => { const displayedStandings = fullStandings.filter(item =>
return teamCard(item, idx + 1, item.pts, item.played); group.teams.some(t => t.name === item.name)
);
const teamsHTML = displayedStandings.map((item) => {
const rank = fullStandings.findIndex(s => s.name === item.name) + 1;
return teamCard(item, rank, item.pts, item.played);
}).join(''); }).join('');
const matchCount = group.matches ? group.matches.length : 0; const fullGroup = appGroups.find(g => g.id === group.id) || group;
const finishedCount = group.matches ? group.matches.filter(m => m.status === 'FT').length : 0; const matchCount = fullGroup.matches ? fullGroup.matches.length : 0;
const finishedCount = fullGroup.matches ? fullGroup.matches.filter(m => m.status === 'FT').length : 0;
const matchCountStr = finishedCount > 0 ? `${finishedCount}/${matchCount} played` : `${matchCount} matches`; const matchCountStr = finishedCount > 0 ? `${finishedCount}/${matchCount} played` : `${matchCount} matches`;
return ` return `
@@ -167,37 +374,41 @@ function getUserLocalTime(match, venue) {
function buildMatchdayHTML(group) { function buildMatchdayHTML(group) {
const matchdayNames = { 1: 'Matchday 1', 2: 'Matchday 2', 3: 'Matchday 3' }; const matchdayNames = { 1: 'Matchday 1', 2: 'Matchday 2', 3: 'Matchday 3' };
const fullGroup = appGroups.find(g => g.id === group.id) || group;
return [1, 2, 3].map(md => { return [1, 2, 3].map(md => {
const dayMatches = group.matches.filter(m => m.matchday === md); const dayMatches = fullGroup.matches.filter(m => m.matchday === md);
const matchesHTML = dayMatches.map((match, idx) => { const matchesHTML = dayMatches.map((match, idx) => {
const venue = venues[match.venue]; const venue = venues[match.venue];
const home = group.teams.find(t => t.name === match.home); const home = fullGroup.teams.find(t => t.name === match.home);
const away = group.teams.find(t => t.name === match.away); const away = fullGroup.teams.find(t => t.name === match.away);
const cardId = `match-${group.id}-${md}-${idx}`; const cardId = `match-${fullGroup.id}-${md}-${idx}`;
const userLocalTimeStr = getUserLocalTime(match, venue); const userLocalTimeStr = getUserLocalTime(match, venue);
const isFinished = match.status === 'FT'; const isFinished = match.status === 'FT';
const isLive = match.isLive;
let scoreDisplayHTML = `<span class="mc-vs">VS</span>`; let scoreDisplayHTML = `<span class="mc-vs">VS</span>`;
if (isFinished && match.score) { if (match.score && match.status && match.status !== 'Scheduled') {
const homeWinnerClass = match.score.home > match.score.away ? 'mc-team-winner' : (match.score.home < match.score.away ? 'mc-team-loser' : 'mc-team-draw'); const homeWinnerClass = isFinished ? (match.score.home > match.score.away ? 'mc-team-winner' : (match.score.home < match.score.away ? 'mc-team-loser' : 'mc-team-draw')) : '';
const awayWinnerClass = match.score.away > match.score.home ? 'mc-team-winner' : (match.score.away < match.score.home ? 'mc-team-loser' : 'mc-team-draw'); const awayWinnerClass = isFinished ? (match.score.away > match.score.home ? 'mc-team-winner' : (match.score.away < match.score.home ? 'mc-team-loser' : 'mc-team-draw')) : '';
scoreDisplayHTML = ` scoreDisplayHTML = `
<div class="mc-score-wrap"> <div class="mc-score-wrap">
<span class="mc-score ${homeWinnerClass}">${match.score.home}</span> <span class="mc-score ${homeWinnerClass}">${match.score.home}</span>
<span class="mc-score-dash"></span> <span class="mc-score-dash"></span>
<span class="mc-score ${awayWinnerClass}">${match.score.away}</span> <span class="mc-score ${awayWinnerClass}">${match.score.away}</span>
<span class="mc-status-badge">FT</span> <span class="mc-status-badge ${isFinished ? 'status-ft' : 'status-live'}">${match.status}</span>
</div> </div>
`; `;
} }
let scorersHTML = ''; let scorersHTML = '';
if (isFinished && match.scorers) { if (match.score && match.scorers) {
const homeScorersList = match.scorers.home ? match.scorers.home.map(s => `${s.name} ${s.min}`).join(', ') : ''; const homeScorersList = match.scorers.home ? match.scorers.home.map(s => `${s.name} ${s.min}`).join(', ') : '';
const awayScorersList = match.scorers.away ? match.scorers.away.map(s => `${s.name} ${s.min}`).join(', ') : ''; const awayScorersList = match.scorers.away ? match.scorers.away.map(s => `${s.name} ${s.min}`).join(', ') : '';
if (homeScorersList || awayScorersList) {
scorersHTML = ` scorersHTML = `
<div class="match-scorers-panel"> <div class="match-scorers-panel">
<div class="scorers-team home-scorers">⚽ ${homeScorersList || '—'}</div> <div class="scorers-team home-scorers">⚽ ${homeScorersList || '—'}</div>
@@ -206,9 +417,14 @@ function buildMatchdayHTML(group) {
</div> </div>
`; `;
} }
}
const hasScoreInput = match.score !== undefined;
const inputHomeVal = hasScoreInput ? match.score.home : '';
const inputAwayVal = hasScoreInput ? match.score.away : '';
return ` return `
<div class="match-card ${isFinished ? 'match-finished' : ''}" style="--match-color:${group.color}" id="${cardId}"> <div class="match-card ${isFinished ? 'match-finished' : ''} ${isLive ? 'match-live' : ''}" style="--match-color:${fullGroup.color}" id="${cardId}">
<button class="match-summary" onclick="toggleMatch('${cardId}')" aria-expanded="false"> <button class="match-summary" onclick="toggleMatch('${cardId}')" aria-expanded="false">
<div class="match-team-col home-col"> <div class="match-team-col home-col">
<span class="mc-flag">${home ? home.flag : '🏴'}</span> <span class="mc-flag">${home ? home.flag : '🏴'}</span>
@@ -237,17 +453,17 @@ function buildMatchdayHTML(group) {
<div class="simulator-row"> <div class="simulator-row">
<div class="sim-team"> <div class="sim-team">
<span class="sim-flag">${home ? home.flag : ''}</span> <span class="sim-flag">${home ? home.flag : ''}</span>
<input type="number" min="0" placeholder="0" class="sim-input" id="input-${cardId}-home" value="${isFinished ? match.score.home : ''}"> <input type="number" min="0" placeholder="0" class="sim-input" id="input-${cardId}-home" value="${inputHomeVal}">
</div> </div>
<span class="sim-vs"></span> <span class="sim-vs"></span>
<div class="sim-team"> <div class="sim-team">
<input type="number" min="0" placeholder="0" class="sim-input" id="input-${cardId}-away" value="${isFinished ? match.score.away : ''}"> <input type="number" min="0" placeholder="0" class="sim-input" id="input-${cardId}-away" value="${inputAwayVal}">
<span class="sim-flag">${away ? away.flag : ''}</span> <span class="sim-flag">${away ? away.flag : ''}</span>
</div> </div>
</div> </div>
<div class="simulator-actions"> <div class="simulator-actions">
<button class="sim-btn sim-btn-save" onclick="saveMatchScore('${group.id}', ${md}, ${idx}, '${cardId}')">Save Score</button> <button class="sim-btn sim-btn-save" onclick="saveMatchScore('${fullGroup.id}', ${md}, ${idx}, '${cardId}')">Save Score</button>
${isFinished ? `<button class="sim-btn sim-btn-clear" onclick="clearMatchScore('${group.id}', ${md}, ${idx}, '${cardId}')">Clear</button>` : ''} ${hasScoreInput ? `<button class="sim-btn sim-btn-clear" onclick="clearMatchScore('${fullGroup.id}', ${md}, ${idx}, '${cardId}')">Clear</button>` : ''}
</div> </div>
</div> </div>
</div> </div>
@@ -356,9 +572,10 @@ function buildStandingsHTML(group) {
// ── Modal ────────────────────────────────────────────────────────────────── // ── Modal ──────────────────────────────────────────────────────────────────
function openModal(group) { function openModal(group) {
if (!group || !group.matches || group.matches.length === 0) return; const fullGroup = appGroups.find(g => g.id === group.id) || group;
if (!fullGroup || !fullGroup.matches || fullGroup.matches.length === 0) return;
const teamsHTML = group.teams.map(t => const teamsHTML = fullGroup.teams.map(t =>
`<div class="modal-team-chip"><span>${t.flag}</span><span>${t.name}</span></div>` `<div class="modal-team-chip"><span>${t.flag}</span><span>${t.name}</span></div>`
).join(''); ).join('');
@@ -366,10 +583,10 @@ function openModal(group) {
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-backdrop" id="modal-backdrop"></div> <div class="modal-backdrop" id="modal-backdrop"></div>
<div class="modal-box"> <div class="modal-box">
<div class="modal-header" style="--group-color:${group.color}"> <div class="modal-header" style="--group-color:${fullGroup.color}">
<div class="modal-title-wrap"> <div class="modal-title-wrap">
<span class="modal-group-label">GROUP</span> <span class="modal-group-label">GROUP</span>
<span class="modal-group-id" style="color:${group.color}">${group.id}</span> <span class="modal-group-id" style="color:${fullGroup.color}">${fullGroup.id}</span>
</div> </div>
<div class="modal-teams-row">${teamsHTML}</div> <div class="modal-teams-row">${teamsHTML}</div>
<button class="modal-close" id="modal-close">✕</button> <button class="modal-close" id="modal-close">✕</button>
@@ -381,13 +598,13 @@ function openModal(group) {
</div> </div>
<div class="modal-body" id="modal-body"> <div class="modal-body" id="modal-body">
<div id="tab-content-schedule" class="tab-content active"> <div id="tab-content-schedule" class="tab-content active">
<div class="matchdays">${buildMatchdayHTML(group)}</div> <div class="matchdays">${buildMatchdayHTML(fullGroup)}</div>
</div> </div>
<div id="tab-content-standings" class="tab-content"> <div id="tab-content-standings" class="tab-content">
${buildStandingsHTML(group)} ${buildStandingsHTML(fullGroup)}
</div> </div>
<div id="tab-content-squad" class="tab-content"> <div id="tab-content-squad" class="tab-content">
${buildSquadHTML(group)} ${buildSquadHTML(fullGroup)}
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -442,25 +659,11 @@ window.saveMatchScore = function(groupId, matchday, matchIdx, cardId) {
const dayMatches = group.matches.filter(m => m.matchday === matchday); const dayMatches = group.matches.filter(m => m.matchday === matchday);
const match = dayMatches[matchIdx]; const match = dayMatches[matchIdx];
if (match) { if (match) {
match.status = 'FT'; const matchKey = getMatchKey(groupId, matchday, match.home, match.away);
match.score = { home: homeScore, away: awayScore }; userSimulations[matchKey] = { home: homeScore, away: awayScore };
saveUserSimulations();
// Preserve or set scorers for Mexico vs South Africa if restored to 2 - 1 resetMatchToBaseState(groupId, matchday, match.home, match.away);
if (groupId === 'A' && matchday === 1 && matchIdx === 0 && homeScore === 2 && awayScore === 1) { updateMatchStates();
match.scorers = {
home: [
{ name: 'Santiago Giménez', min: "24'" },
{ name: 'Henry Martín', min: "78'" }
],
away: [
{ name: 'Percy Tau', min: "41' (pen)" }
]
};
} else {
delete match.scorers;
}
saveState();
render(); render();
const updatedGroup = appGroups.find(g => g.id === groupId); const updatedGroup = appGroups.find(g => g.id === groupId);
@@ -479,11 +682,11 @@ window.clearMatchScore = function(groupId, matchday, matchIdx, cardId) {
const dayMatches = group.matches.filter(m => m.matchday === matchday); const dayMatches = group.matches.filter(m => m.matchday === matchday);
const match = dayMatches[matchIdx]; const match = dayMatches[matchIdx];
if (match) { if (match) {
delete match.status; const matchKey = getMatchKey(groupId, matchday, match.home, match.away);
delete match.score; delete userSimulations[matchKey];
delete match.scorers; saveUserSimulations();
resetMatchToBaseState(groupId, matchday, match.home, match.away);
saveState(); updateMatchStates();
render(); render();
const updatedGroup = appGroups.find(g => g.id === groupId); const updatedGroup = appGroups.find(g => g.id === groupId);
@@ -498,8 +701,10 @@ window.clearMatchScore = function(groupId, matchday, matchIdx, cardId) {
window.resetAllScores = function() { window.resetAllScores = function() {
if (confirm('Are you sure you want to reset all simulated scores back to default?')) { if (confirm('Are you sure you want to reset all simulated scores back to default?')) {
localStorage.removeItem('wc2026_groups'); localStorage.removeItem('wc2026_user_sims');
loadState(); loadUserSimulations();
appGroups = JSON.parse(JSON.stringify(groups));
updateMatchStates();
render(); render();
closeModal(); closeModal();
animateCounters(); animateCounters();
@@ -522,11 +727,220 @@ window.toggleMatch = function(cardId) {
} }
}; };
// ── Rankings & Scorers Helpers ─────────────────────────────────────────────
function calculateOverallRankings() {
let allStandings = [];
appGroups.forEach(group => {
const groupStandings = calculateStandings(group);
groupStandings.forEach((teamStandings, idx) => {
allStandings.push({
...teamStandings,
groupId: group.id,
groupRank: idx + 1
});
});
});
return allStandings.sort((a, b) => {
if (b.pts !== a.pts) return b.pts - a.pts;
if (b.gd !== a.gd) return b.gd - a.gd;
if (b.gf !== a.gf) return b.gf - a.gf;
return a.name.localeCompare(b.name);
});
}
function buildOverallRankingsHTML() {
const sortedTeams = calculateOverallRankings();
let filtered = sortedTeams;
if (activeFilter !== 'all') {
filtered = filtered.filter(t => t.confederation === activeFilter);
}
if (searchQuery) {
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (filtered.length === 0) {
return `<div class="no-results"><div class="no-results-icon">🔍</div><p>No teams found.</p></div>`;
}
const rows = filtered.map((team, idx) => {
const overallRank = sortedTeams.findIndex(t => t.name === team.name) + 1;
const rankClass = overallRank <= 32 ? 'rank-adv' : 'rank-el';
return `
<tr>
<td class="col-pos"><span class="table-rank-num ${rankClass}">${overallRank}</span></td>
<td class="col-team">
<span class="table-flag">${team.flag}</span>
<span class="table-team-name">${team.name}</span>
<span class="table-group-badge">Group ${team.groupId}</span>
${team.isHost ? '<span class="table-host-badge" title="Host">🏟️</span>' : ''}
</td>
<td class="col-stat text-hide-mobile">${team.confederation}</td>
<td class="col-stat">${team.played}</td>
<td class="col-stat font-w-600">${team.won}</td>
<td class="col-stat">${team.drawn}</td>
<td class="col-stat">${team.lost}</td>
<td class="col-stat text-hide-mobile">${team.gf}</td>
<td class="col-stat text-hide-mobile">${team.ga}</td>
<td class="col-stat font-w-600">${team.gd > 0 ? '+' + team.gd : team.gd}</td>
<td class="col-stat col-pts">${team.pts}</td>
</tr>
`;
}).join('');
return `
<div class="overall-rankings-container animate-in">
<div class="view-header">
<h2 class="view-title">📈 48-Team Overall Rankings</h2>
<p class="view-description">Live rankings of all countries participating in the 2026 FIFA World Cup, compiled from current group stage results.</p>
</div>
<div class="standings-wrapper">
<table class="standings-table overall-table">
<thead>
<tr>
<th class="col-pos">#</th>
<th class="col-team">Team</th>
<th class="col-stat text-hide-mobile">Conf.</th>
<th class="col-stat">P</th>
<th class="col-stat">W</th>
<th class="col-stat">D</th>
<th class="col-stat">L</th>
<th class="col-stat text-hide-mobile">GF</th>
<th class="col-stat text-hide-mobile">GA</th>
<th class="col-stat">GD</th>
<th class="col-stat col-pts">Pts</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</div>
</div>
`;
}
function getTopScorers() {
const scorersMap = {};
appGroups.forEach(group => {
group.matches.forEach(match => {
if (match.score && match.status && match.status !== 'Scheduled' && match.scorers) {
if (match.scorers.home) {
match.scorers.home.forEach(s => {
if (s.name.includes('(OG)') || s.name.includes('OG') || s.name.includes('Own Goal')) return;
const name = s.name.replace(/\s*\(pen\)/g, '').trim();
if (!scorersMap[name]) {
scorersMap[name] = { name, goals: 0, team: match.home, flag: '' };
const teamObj = group.teams.find(t => t.name === match.home);
if (teamObj) scorersMap[name].flag = teamObj.flag;
}
scorersMap[name].goals += 1;
});
}
if (match.scorers.away) {
match.scorers.away.forEach(s => {
if (s.name.includes('(OG)') || s.name.includes('OG') || s.name.includes('Own Goal')) return;
const name = s.name.replace(/\s*\(pen\)/g, '').trim();
if (!scorersMap[name]) {
scorersMap[name] = { name, goals: 0, team: match.away, flag: '' };
const teamObj = group.teams.find(t => t.name === match.away);
if (teamObj) scorersMap[name].flag = teamObj.flag;
}
scorersMap[name].goals += 1;
});
}
}
});
});
return Object.values(scorersMap).sort((a, b) => {
if (b.goals !== a.goals) return b.goals - a.goals;
return a.name.localeCompare(b.name);
});
}
function buildTopScorersHTML() {
const scorers = getTopScorers();
let filtered = scorers;
if (searchQuery) {
filtered = filtered.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.team.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (filtered.length === 0) {
return `<div class="no-results"><div class="no-results-icon">⚽</div><p>No goalscorers found.</p></div>`;
}
const rows = filtered.map((player, idx) => {
const rank = scorers.findIndex(s => s.name === player.name) + 1;
const rankClass = rank === 1 ? 'rank-gold' : rank === 2 ? 'rank-silver' : rank === 3 ? 'rank-bronze' : '';
const trophy = rank === 1 ? '🏆' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : `#${rank}`;
return `
<div class="scorer-card">
<div class="scorer-rank ${rankClass}">${trophy}</div>
<div class="scorer-details">
<span class="scorer-name">${player.name}</span>
<span class="scorer-team">${player.flag} ${player.team}</span>
</div>
<div class="scorer-goals">
<span class="goals-count">${player.goals}</span>
<span class="goals-label">${player.goals === 1 ? 'goal' : 'goals'}</span>
</div>
</div>
`;
}).join('');
return `
<div class="top-scorers-container animate-in">
<div class="view-header">
<h2 class="view-title">⚽ Golden Boot Standings</h2>
<p class="view-description">Top individual goalscorers of the 2026 FIFA World Cup. Simulated and live goals included.</p>
</div>
<div class="scorers-grid">
${rows}
</div>
</div>
`;
}
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
function render() { let renderCache = {};
let lastFilterState = '';
function render(forceFull = false) {
const grid = document.getElementById('groups-grid'); const grid = document.getElementById('groups-grid');
if (!grid) return; if (!grid) return;
const currentFilterState = `${activeView}-${activeFilter}-${searchQuery}`;
if (currentFilterState !== lastFilterState) {
forceFull = true;
lastFilterState = currentFilterState;
}
if (activeView === 'rankings') {
grid.style.display = 'block';
grid.innerHTML = buildOverallRankingsHTML();
renderCache = {};
return;
}
if (activeView === 'scorers') {
grid.style.display = 'block';
grid.innerHTML = buildTopScorersHTML();
renderCache = {};
return;
}
grid.style.display = 'grid';
const filteredGroups = appGroups.map(group => { const filteredGroups = appGroups.map(group => {
let filteredTeams = group.teams; let filteredTeams = group.teams;
if (activeFilter !== 'all') { if (activeFilter !== 'all') {
@@ -542,10 +956,17 @@ function render() {
if (filteredGroups.length === 0) { if (filteredGroups.length === 0) {
grid.innerHTML = `<div class="no-results"><div class="no-results-icon">🔍</div><p>No teams found.</p></div>`; grid.innerHTML = `<div class="no-results"><div class="no-results-icon">🔍</div><p>No teams found.</p></div>`;
renderCache = {};
return; return;
} }
grid.innerHTML = filteredGroups.map(groupCard).join(''); if (forceFull || Object.keys(renderCache).length === 0) {
renderCache = {};
grid.innerHTML = filteredGroups.map(group => {
const html = groupCard(group);
renderCache[group.id] = html;
return html;
}).join('');
// Animate cards in // Animate cards in
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -554,6 +975,30 @@ function render() {
card.classList.add('animate-in'); card.classList.add('animate-in');
}); });
}); });
} else {
// Targeted update: only re-render groups that actually changed
filteredGroups.forEach(group => {
const html = groupCard(group);
if (renderCache[group.id] !== html) {
const cardEl = document.querySelector(`.group-card[data-group="${group.id}"]`);
if (cardEl) {
cardEl.outerHTML = html;
// Re-trigger animate-in on updated card
const newCardEl = document.querySelector(`.group-card[data-group="${group.id}"]`);
if (newCardEl) {
newCardEl.classList.add('animate-in');
}
} else {
forceFull = true;
}
renderCache[group.id] = html;
}
});
if (forceFull) {
render(true);
}
}
} }
// ── Global click handler (set ONCE on document.body) ────────────────────── // ── Global click handler (set ONCE on document.body) ──────────────────────
@@ -573,6 +1018,28 @@ function setupGlobalClick() {
}); });
} }
// ── View Switcher Tabs ─────────────────────────────────────────────────────
function setupViewTabs() {
document.querySelectorAll('.view-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeView = btn.dataset.view;
const filterNav = document.querySelector('.filter-nav');
if (filterNav) {
if (activeView === 'scorers') {
filterNav.style.display = 'none';
} else {
filterNav.style.display = 'flex';
}
}
render(true);
});
});
}
// ── Filter buttons ───────────────────────────────────────────────────────── // ── Filter buttons ─────────────────────────────────────────────────────────
function setupFilters() { function setupFilters() {
document.querySelectorAll('.filter-btn').forEach(btn => { document.querySelectorAll('.filter-btn').forEach(btn => {
@@ -653,9 +1120,15 @@ function buildShell() {
<div class="controls-bar" id="filter-bar"> <div class="controls-bar" id="filter-bar">
<div class="controls-inner"> <div class="controls-inner">
<div class="view-tabs">
<button class="view-tab-btn active" data-view="groups">🗂️ Groups</button>
<button class="view-tab-btn" data-view="rankings">📈 Overall Ranking</button>
<button class="view-tab-btn" data-view="scorers">⚽ Top Scorers</button>
</div>
<div class="controls-divider text-hide-mobile"></div>
<div class="search-wrap"> <div class="search-wrap">
<span class="search-icon">🔍</span> <span class="search-icon">🔍</span>
<input type="text" id="search-input" class="search-input" placeholder="Search team…" autocomplete="off" spellcheck="false" /> <input type="text" id="search-input" class="search-input" placeholder="Search team/player…" autocomplete="off" spellcheck="false" />
</div> </div>
<nav class="filter-nav"> <nav class="filter-nav">
<button class="filter-btn active" data-filter="all">🌍 All</button> <button class="filter-btn active" data-filter="all">🌍 All</button>
@@ -711,13 +1184,57 @@ function setupTheme() {
} }
} }
// ── Init ─────────────────────────────────────────────────────────────────── // ── Init & Live Ticking Loop ───────────────────────────────────────────────
function initLiveTicking() {
updateMatchStates();
render();
// Tick every 5 seconds locally in-memory (0 network overhead)
setInterval(() => {
updateMatchStates();
render();
// Auto-update modal if open and user is not currently focused on typing simulated scores
const modal = document.getElementById('modal');
if (modal && modal.classList.contains('open')) {
const isSimulating = document.querySelector('.sim-input:focus') !== null;
if (!isSimulating) {
const modalGroupIdElement = document.querySelector('.modal-group-id');
if (modalGroupIdElement) {
const groupId = modalGroupIdElement.textContent.trim();
const group = appGroups.find(g => g.id === groupId);
if (group) {
const activeTabButton = document.querySelector('.modal-tab.active');
const activeTab = activeTabButton ? activeTabButton.id.replace('tab-', '') : 'schedule';
openModal(group);
switchTab(activeTab);
}
}
}
}
}, 5000);
// Sync with server once every 60 seconds (prevents rate limit blocking)
setInterval(async () => {
await fetchScores();
updateMatchStates();
render();
}, 60000);
}
buildShell(); buildShell();
setupTheme(); setupTheme();
setupGlobalClick(); // ← set ONCE after shell exists, never re-added setupGlobalClick(); // ← set ONCE after shell exists, never re-added
render();
loadUserSimulations();
Promise.all([fetchScores(), fetchScorers()]).then(() => {
appGroups = JSON.parse(JSON.stringify(groups));
initLiveTicking();
setupViewTabs();
setupFilters(); setupFilters();
setupSearch(); setupSearch();
setupScrollBehavior(); setupScrollBehavior();
setupKeyboard(); setupKeyboard();
animateCounters(); animateCounters();
});
+155
View File
@@ -1168,6 +1168,161 @@ body {
color: #ef4444; color: #ef4444;
} }
/* ── Live Match indicators ── */
.mc-status-badge.status-live {
background: #ef4444;
color: #ffffff;
animation: pulse-live 1.5s infinite alternate;
}
.match-card.match-live {
border-color: #ef4444;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
}
@keyframes pulse-live {
from { opacity: 0.8; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
to { opacity: 1; box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
}
/* ── View Switcher Tabs ── */
.view-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.view-tab-btn {
background: var(--bg-element);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-muted);
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 6px 14px;
transition: all var(--transition);
white-space: nowrap;
}
.view-tab-btn:hover {
background: var(--bg-element-hover);
border-color: var(--border-hover);
color: var(--text-primary);
}
.view-tab-btn.active {
background: linear-gradient(135deg, var(--gold) 0%, #ffaa00 100%);
border-color: var(--gold);
color: #000000;
box-shadow: 0 0 12px rgba(255, 215, 0, 0.25);
}
.controls-divider {
width: 1px;
height: 24px;
background: var(--border);
flex-shrink: 0;
margin: 0 4px;
}
/* ── View Header & Content ── */
.view-header {
margin-bottom: 24px;
}
.view-title {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.view-description {
font-size: 14px;
color: var(--text-muted);
}
/* ── Overall Rankings ── */
.table-group-badge {
font-size: 9px;
font-weight: 700;
background: var(--bg-element);
color: var(--text-muted);
padding: 1px 6px;
border-radius: 8px;
margin-left: 6px;
text-transform: uppercase;
}
/* ── Top Scorers ── */
.scorers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.scorer-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 18px;
display: flex;
align-items: center;
gap: 14px;
transition: all var(--transition);
}
.scorer-card:hover {
border-color: var(--border-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.scorer-rank {
font-size: 20px;
font-weight: 800;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-element);
color: var(--text-muted);
}
.scorer-rank.rank-gold { background: rgba(255, 215, 0, 0.2); color: var(--gold); }
.scorer-rank.rank-silver { background: rgba(192, 192, 192, 0.2); color: #c0c0c0; }
.scorer-rank.rank-bronze { background: rgba(205, 127, 50, 0.2); color: #cd7f32; }
.scorer-details {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.scorer-name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.scorer-team {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.scorer-goals {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.goals-count {
font-size: 22px;
font-weight: 900;
color: var(--gold);
line-height: 1;
}
.goals-label {
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
margin-top: 2px;
letter-spacing: 0.5px;
}
@media (min-width: 1200px) { @media (min-width: 1200px) {
.groups-grid { grid-template-columns: repeat(4, 1fr); } .groups-grid { grid-template-columns: repeat(4, 1fr); }
} }