update
dongho-repo/worldcup2026/pipeline/head This commit looks good

This commit is contained in:
Dongho Kim
2026-06-12 22:53:08 +02:00
parent 805248f6d2
commit d5c9765970
4 changed files with 765 additions and 58 deletions
+11
View File
@@ -0,0 +1,11 @@
services:
worldcup-app-local:
build:
context: .
dockerfile: Dockerfile
container_name: 2026-worldcup-local
restart: unless-stopped
environment:
- NODE_ENV=development
ports:
- "8080:80"
+83 -47
View File
@@ -32,12 +32,48 @@ export const groups = [
{ name: 'Czech Republic',flag: '🇨🇿', confederation: 'UEFA', isHost: false },
],
matches: [
{ matchday: 1, home: 'Mexico', away: 'South Africa', date: 'Thu, Jun 11', time: '19:00', venue: 'Mexico City' },
{ matchday: 1, home: 'South Korea', away: 'Czech Republic',date: 'Fri, Jun 12', time: '20:00', venue: 'Guadalajara' },
{ matchday: 2, home: 'Mexico', away: 'Czech Republic',date: 'Mon, Jun 15', time: '22:00', venue: 'Monterrey' },
{ matchday: 2, home: 'South Korea', away: 'South Africa', date: 'Tue, Jun 16', time: '19:00', venue: 'Los Angeles' },
{ matchday: 3, home: 'Mexico', away: 'South Korea', date: 'Sat, Jun 21', time: '18:00', venue: 'Dallas' },
{ matchday: 3, home: 'Czech Republic',away:'South Africa', date: 'Sat, Jun 21', time: '18:00', venue: 'Atlanta' },
{
matchday: 1,
home: 'Mexico',
away: 'South Africa',
date: 'Thu, Jun 11',
time: '19:00',
venue: 'Mexico City',
status: 'FT',
score: { home: 2, away: 1 },
scorers: {
home: [
{ name: 'Santiago Giménez', min: "24'" },
{ name: 'Henry Martín', min: "78'" }
],
away: [
{ name: 'Percy Tau', min: "41' (pen)" }
]
}
},
{
matchday: 1,
home: 'South Korea',
away: 'Czech Republic',
date: 'Thu, Jun 11',
time: '21:00',
venue: 'Guadalajara',
status: 'FT',
score: { home: 2, away: 1 },
scorers: {
home: [
{ name: 'Hwang In-beom', min: "67'" },
{ name: 'Oh Hyeon-gyu', min: "80'" }
],
away: [
{ name: 'Ladislav Krejčí', min: "59'" }
]
}
},
{ matchday: 2, home: 'Czech Republic', away: 'South Africa', date: 'Wed, Jun 18', time: '18:00', venue: 'Atlanta' },
{ matchday: 2, home: 'Mexico', away: 'South Korea', date: 'Wed, Jun 18', time: '21:00', venue: 'Guadalajara' },
{ matchday: 3, home: 'Czech Republic', away: 'Mexico', date: 'Tue, Jun 24', time: '18:00', venue: 'Monterrey' },
{ matchday: 3, home: 'South Africa', away: 'South Korea', date: 'Tue, Jun 24', time: '21:00', venue: 'Monterrey' },
],
},
{
@@ -51,11 +87,11 @@ export const groups = [
],
matches: [
{ matchday: 1, home: 'Canada', away: 'Bosnia & Herzegovina', date: 'Fri, Jun 12', time: '19:00', venue: 'Toronto' },
{ matchday: 1, home: 'Qatar', away: 'Switzerland', date: 'Sat, Jun 13', time: '16:00', venue: 'San Francisco'},
{ matchday: 2, home: 'Canada', away: 'Switzerland', date: 'Tue, Jun 16', time: '16:00', venue: 'Vancouver' },
{ matchday: 2, home: 'Qatar', away: 'Bosnia & Herzegovina', date: 'Wed, Jun 17', time: '19:00', venue: 'Toronto' },
{ matchday: 3, home: 'Canada', away: 'Qatar', date: 'Sun, Jun 22', time: '18:00', venue: 'Vancouver' },
{ matchday: 3, home: 'Switzerland', away: 'Bosnia & Herzegovina', date: 'Sun, Jun 22', time: '18:00', venue: 'Kansas City' },
{ matchday: 1, home: 'Qatar', away: 'Switzerland', date: 'Sat, Jun 13', time: '19:00', venue: 'San Francisco' },
{ matchday: 2, home: 'Switzerland', away: 'Bosnia & Herzegovina', date: 'Wed, Jun 18', time: '19:00', venue: 'Los Angeles' },
{ matchday: 2, home: 'Canada', away: 'Qatar', date: 'Wed, Jun 18', time: '22:00', venue: 'Vancouver' },
{ matchday: 3, home: 'Bosnia & Herzegovina', away: 'Qatar', date: 'Tue, Jun 24', time: '19:00', venue: 'Seattle' },
{ matchday: 3, home: 'Switzerland', away: 'Canada', date: 'Tue, Jun 24', time: '19:00', venue: 'Vancouver' },
],
},
{
@@ -70,10 +106,10 @@ export const groups = [
matches: [
{ matchday: 1, home: 'Brazil', away: 'Morocco', date: 'Sat, Jun 13', time: '19:00', venue: 'New York' },
{ matchday: 1, home: 'Haiti', away: 'Scotland', date: 'Sat, Jun 13', time: '16:00', venue: 'Boston' },
{ matchday: 2, home: 'Brazil', away: 'Scotland', date: 'Wed, Jun 17', time: '16:00', venue: 'Los Angeles' },
{ matchday: 2, home: 'Morocco', away: 'Haiti', date: 'Wed, Jun 17', time: '22:00', venue: 'Miami' },
{ matchday: 3, home: 'Brazil', away: 'Haiti', date: 'Mon, Jun 22', time: '18:00', venue: 'Dallas' },
{ matchday: 3, home: 'Scotland', away: 'Morocco', date: 'Mon, Jun 22', time: '18:00', venue: 'Philadelphia' },
{ matchday: 2, home: 'Brazil', away: 'Scotland', date: 'Fri, Jun 19', time: '19:00', venue: 'Los Angeles' },
{ matchday: 2, home: 'Morocco', away: 'Haiti', date: 'Fri, Jun 19', time: '22:00', venue: 'Miami' },
{ matchday: 3, home: 'Brazil', away: 'Haiti', date: 'Tue, Jun 24', time: '18:00', venue: 'Dallas' },
{ matchday: 3, home: 'Scotland', away: 'Morocco', date: 'Tue, Jun 24', time: '18:00', venue: 'Philadelphia' },
],
},
{
@@ -86,12 +122,12 @@ export const groups = [
{ name: 'Türkiye', flag: '🇹🇷', confederation: 'UEFA', isHost: false },
],
matches: [
{ matchday: 1, home: 'United States', away: 'Paraguay', date: 'Fri, Jun 12', time: '22:00', venue: 'Los Angeles' },
{ matchday: 1, home: 'Australia', away: 'Türkiye', date: 'Sat, Jun 13', time: '22:00', venue: 'Vancouver' },
{ matchday: 2, home: 'United States', away: 'Türkiye', date: 'Wed, Jun 17', time: '22:00', venue: 'Seattle' },
{ matchday: 2, home: 'Paraguay', away: 'Australia',date: 'Thu, Jun 18', time: '19:00', venue: 'Atlanta' },
{ matchday: 3, home: 'United States', away: 'Australia',date: 'Mon, Jun 22', time: '22:00', venue: 'Dallas' },
{ matchday: 3, home: 'Türkiye', away: 'Paraguay', date: 'Mon, Jun 22', time: '22:00', venue: 'Houston' },
{ matchday: 1, home: 'United States', away: 'Paraguay', date: 'Fri, Jun 12', time: '21:00', venue: 'Los Angeles' },
{ matchday: 1, home: 'Australia', away: 'Türkiye', date: 'Sun, Jun 13', time: '00:00', venue: 'Vancouver' },
{ matchday: 2, home: 'United States', away: 'Australia', date: 'Thu, Jun 19', time: '15:00', venue: 'Seattle' },
{ matchday: 2, home: 'Türkiye', away: 'Paraguay', date: 'Thu, Jun 19', time: '23:00', venue: 'San Francisco'},
{ matchday: 3, home: 'Türkiye', away: 'United States', date: 'Thu, Jun 25', time: '22:00', venue: 'Los Angeles' },
{ matchday: 3, home: 'Paraguay', away: 'Australia', date: 'Thu, Jun 25', time: '22:00', venue: 'San Francisco'},
],
},
{
@@ -106,10 +142,10 @@ export const groups = [
matches: [
{ matchday: 1, home: 'Germany', away: 'Curaçao', date: 'Sun, Jun 14', time: '16:00', venue: 'Kansas City' },
{ matchday: 1, home: "Côte d'Ivoire", away: 'Ecuador', date: 'Sun, Jun 14', time: '19:00', venue: 'Atlanta' },
{ matchday: 2, home: 'Germany', away: 'Ecuador', date: 'Thu, Jun 18', time: '16:00', venue: 'Seattle' },
{ matchday: 2, home: 'Curaçao', away: "Côte d'Ivoire", date: 'Thu, Jun 18', time: '22:00', venue: 'Houston' },
{ matchday: 3, home: 'Germany', away: "Côte d'Ivoire", date: 'Tue, Jun 23', time: '18:00', venue: 'Philadelphia'},
{ matchday: 3, home: 'Ecuador', away: 'Curaçao', date: 'Tue, Jun 23', time: '18:00', venue: 'Miami' },
{ matchday: 2, home: 'Germany', away: "Côte d'Ivoire", date: 'Fri, Jun 20', time: '18:00', venue: 'Philadelphia'},
{ matchday: 2, home: 'Ecuador', away: 'Curaçao', date: 'Fri, Jun 20', time: '18:00', venue: 'Miami' },
{ matchday: 3, home: 'Curaçao', away: "Côte d'Ivoire", date: 'Wed, Jun 25', time: '18:00', venue: 'Houston' },
{ matchday: 3, home: 'Ecuador', away: 'Germany', date: 'Wed, Jun 25', time: '18:00', venue: 'Seattle' },
],
},
{
@@ -124,10 +160,10 @@ export const groups = [
matches: [
{ 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: 2, home: 'Netherlands', away: 'Tunisia', date: 'Fri, Jun 19', time: '16:00', venue: 'Dallas' },
{ matchday: 2, home: 'Japan', away: 'Sweden', date: 'Fri, Jun 19', time: '22:00', venue: 'Seattle' },
{ matchday: 3, home: 'Netherlands', away: 'Sweden', date: 'Tue, Jun 23', time: '22:00', venue: 'New York' },
{ matchday: 3, home: 'Tunisia', away: 'Japan', date: 'Tue, Jun 23', time: '22:00', venue: 'Kansas City' },
{ 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: 3, home: 'Netherlands', away: 'Tunisia', date: 'Fri, Jun 26', time: '18:00', venue: 'Dallas' },
{ matchday: 3, home: 'Japan', away: 'Sweden', date: 'Fri, Jun 26', time: '18:00', venue: 'Seattle' },
],
},
{
@@ -142,10 +178,10 @@ export const groups = [
matches: [
{ matchday: 1, home: 'Belgium', away: 'Egypt', date: 'Mon, Jun 15', time: '19:00', venue: 'Miami' },
{ matchday: 1, home: 'Iran', away: 'New Zealand', date: 'Mon, Jun 15', time: '22:00', venue: 'San Francisco'},
{ matchday: 2, home: 'Belgium', away: 'New Zealand', date: 'Sat, Jun 19', time: '16:00', venue: 'Philadelphia'},
{ matchday: 2, home: 'Egypt', away: 'Iran', date: 'Sat, Jun 19', time: '19:00', venue: 'Houston' },
{ matchday: 3, home: 'Belgium', away: 'Iran', date: 'Wed, Jun 24', time: '18:00', venue: 'Atlanta' },
{ matchday: 3, home: 'New Zealand', away: 'Egypt', date: 'Wed, Jun 24', time: '18:00', venue: 'Boston' },
{ matchday: 2, home: 'Belgium', away: 'New Zealand', date: 'Sun, Jun 21', time: '16:00', venue: 'Philadelphia'},
{ matchday: 2, home: 'Egypt', away: 'Iran', date: 'Sun, Jun 21', time: '19:00', venue: 'Houston' },
{ matchday: 3, home: 'Belgium', away: 'Iran', date: 'Thu, Jun 26', time: '18:00', venue: 'Atlanta' },
{ matchday: 3, home: 'New Zealand', away: 'Egypt', date: 'Thu, Jun 26', time: '18:00', venue: 'Boston' },
],
},
{
@@ -160,10 +196,10 @@ export const groups = [
matches: [
{ matchday: 1, home: 'Spain', away: 'Cape Verde', date: 'Mon, Jun 15', time: '16:00', venue: 'Guadalajara' },
{ 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 20', time: '16:00', venue: 'Monterrey' },
{ matchday: 2, home: 'Cape Verde', away: 'Saudi Arabia', date: 'Sat, Jun 20', time: '22:00', venue: 'Guadalajara' },
{ matchday: 3, home: 'Spain', away: 'Saudi Arabia', date: 'Wed, Jun 24', time: '22:00', venue: 'Dallas' },
{ matchday: 3, home: 'Uruguay', away: 'Cape Verde', date: 'Wed, Jun 24', time: '22:00', venue: 'Miami' },
{ 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: 3, home: 'Spain', away: 'Saudi Arabia', date: 'Thu, Jun 26', time: '18:00', venue: 'Dallas' },
{ matchday: 3, home: 'Uruguay', away: 'Cape Verde', date: 'Thu, Jun 26', time: '18:00', venue: 'Miami' },
],
},
{
@@ -178,10 +214,10 @@ export const groups = [
matches: [
{ matchday: 1, home: 'France', away: 'Senegal', date: 'Tue, Jun 16', time: '22:00', venue: 'Houston' },
{ matchday: 1, home: 'Iraq', away: 'Norway', date: 'Tue, Jun 16', time: '19:00', venue: 'Philadelphia'},
{ matchday: 2, home: 'France', away: 'Norway', date: 'Sat, Jun 20', time: '19:00', venue: 'New York' },
{ matchday: 2, home: 'Senegal', away: 'Iraq', date: 'Sun, Jun 21', time: '16:00', venue: 'Atlanta' },
{ matchday: 3, home: 'France', away: 'Iraq', date: 'Thu, Jun 25', time: '18:00', venue: 'Seattle' },
{ matchday: 3, home: 'Norway', away: 'Senegal', date: 'Thu, Jun 25', time: '18:00', venue: 'Boston' },
{ matchday: 2, home: 'France', away: 'Norway', date: 'Sun, Jun 22', time: '19:00', venue: 'New York' },
{ matchday: 2, home: 'Senegal', away: 'Iraq', date: 'Sun, Jun 22', time: '16:00', venue: 'Atlanta' },
{ matchday: 3, home: 'France', away: 'Iraq', date: 'Thu, Jun 26', time: '18:00', venue: 'Seattle' },
{ matchday: 3, home: 'Norway', away: 'Senegal', date: 'Thu, Jun 26', time: '18:00', venue: 'Boston' },
],
},
{
@@ -230,12 +266,12 @@ export const groups = [
{ name: 'Panama', flag: '🇵🇦', confederation: 'CONCACAF', isHost: false },
],
matches: [
{ matchday: 1, home: 'England', away: 'Panama', date: 'Fri, Jun 19', time: '16:00', venue: 'Houston' },
{ matchday: 1, home: 'Croatia', away: 'Ghana', date: 'Fri, Jun 19', time: '19:00', venue: 'New York' },
{ matchday: 2, home: 'England', away: 'Ghana', date: 'Tue, Jun 23', time: '16:00', venue: 'Philadelphia'},
{ matchday: 2, home: 'Panama', away: 'Croatia', date: 'Tue, Jun 23', time: '19:00', venue: 'Miami' },
{ matchday: 3, home: 'England', away: 'Croatia', date: 'Sat, Jun 27', time: '18:00', venue: 'San Francisco'},
{ matchday: 3, home: 'Ghana', away: 'Panama', date: 'Sat, Jun 27', time: '18:00', venue: 'Atlanta' },
{ matchday: 1, home: 'England', away: 'Croatia', date: 'Tue, Jun 17', time: '15:00', venue: 'Dallas' },
{ matchday: 1, home: 'Ghana', away: 'Panama', date: 'Tue, Jun 17', time: '19:00', venue: 'Toronto' },
{ matchday: 2, home: 'England', away: 'Ghana', date: 'Mon, Jun 23', time: '16:00', venue: 'Boston' },
{ matchday: 2, home: 'Panama', away: 'Croatia', date: 'Mon, Jun 23', time: '19:00', venue: 'Miami' },
{ matchday: 3, home: 'Panama', away: 'England', date: 'Fri, Jun 27', time: '18:00', venue: 'New York' },
{ matchday: 3, home: 'Croatia', away: 'Ghana', date: 'Fri, Jun 27', time: '18:00', venue: 'Atlanta' },
],
},
];
+310 -10
View File
@@ -7,6 +7,26 @@ console.log('[WC2026] Loaded groups:', groups.length, '| First group matches:',
// ── State ──────────────────────────────────────────────────────────────────
let activeFilter = 'all';
let searchQuery = '';
let appGroups = [];
function loadState() {
const saved = localStorage.getItem('wc2026_groups');
if (saved) {
try {
appGroups = JSON.parse(saved);
return;
} catch (e) {
console.error('[WC2026] Failed to load saved state:', e);
}
}
appGroups = JSON.parse(JSON.stringify(groups));
}
function saveState() {
localStorage.setItem('wc2026_groups', JSON.stringify(appGroups));
}
loadState();
// ── Helpers ────────────────────────────────────────────────────────────────
function confBadge(confederation) {
@@ -14,21 +34,94 @@ function confBadge(confederation) {
return `<span class="conf-badge" style="background:${c.bg};color:${c.accent}">${confederation}</span>`;
}
function teamCard(team) {
function calculateStandings(group) {
const standings = {};
group.teams.forEach(team => {
standings[team.name] = {
name: team.name,
flag: team.flag,
confederation: team.confederation,
isHost: team.isHost,
played: 0,
won: 0,
drawn: 0,
lost: 0,
gf: 0,
ga: 0,
gd: 0,
pts: 0
};
});
group.matches.forEach(match => {
if (match.status === 'FT' && match.score) {
const home = match.home;
const away = match.away;
const homeScore = parseInt(match.score.home, 10);
const awayScore = parseInt(match.score.away, 10);
if (!isNaN(homeScore) && !isNaN(awayScore) && standings[home] && standings[away]) {
standings[home].played += 1;
standings[away].played += 1;
standings[home].gf += homeScore;
standings[home].ga += awayScore;
standings[away].gf += awayScore;
standings[away].ga += homeScore;
standings[home].gd = standings[home].gf - standings[home].ga;
standings[away].gd = standings[away].gf - standings[away].ga;
if (homeScore > awayScore) {
standings[home].won += 1;
standings[home].pts += 3;
standings[away].lost += 1;
} else if (homeScore < awayScore) {
standings[away].won += 1;
standings[away].pts += 3;
standings[home].lost += 1;
} else {
standings[home].drawn += 1;
standings[home].pts += 1;
standings[away].drawn += 1;
standings[away].pts += 1;
}
}
}
});
return Object.values(standings).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 teamCard(team, rank, pts, played) {
const hostBadge = team.isHost ? '<span class="host-badge">🏟️ Host</span>' : '';
const ptsBadge = played > 0 ? `<span class="team-pts-badge">${pts} PTS</span>` : '';
const rankClass = rank <= 2 ? 'rank-adv' : rank === 3 ? 'rank-pot' : 'rank-el';
return `
<div class="team-card">
<span class="team-rank ${rankClass}">${rank}</span>
<span class="team-flag">${team.flag}</span>
<div class="team-info">
<span class="team-name">${team.name}</span>
<div class="team-meta">${confBadge(team.confederation)}${hostBadge}</div>
</div>
${ptsBadge}
</div>`;
}
function groupCard(group) {
const teamsHTML = group.teams.map(teamCard).join('');
const standings = calculateStandings(group);
const teamsHTML = standings.map((item, idx) => {
return teamCard(item, idx + 1, item.pts, item.played);
}).join('');
const matchCount = group.matches ? group.matches.length : 0;
const finishedCount = group.matches ? group.matches.filter(m => m.status === 'FT').length : 0;
const matchCountStr = finishedCount > 0 ? `${finishedCount}/${matchCount} played` : `${matchCount} matches`;
return `
<article class="group-card" data-group="${group.id}">
<div class="group-header" style="--group-color:${group.color}">
@@ -37,13 +130,13 @@ function groupCard(group) {
<h2 class="group-id">${group.id}</h2>
</div>
<div class="group-right">
<span class="match-count">${matchCount} matches</span>
<span class="match-count">${matchCountStr}</span>
<div class="group-dot" style="background:${group.color}"></div>
</div>
</div>
<div class="team-list">${teamsHTML}</div>
<div class="card-footer">
<span class="view-schedule-hint">📅 Click to view match schedule</span>
<span class="view-schedule-hint">📅 Click to view schedule & standings</span>
</div>
</article>`;
}
@@ -82,29 +175,81 @@ function buildMatchdayHTML(group) {
const away = group.teams.find(t => t.name === match.away);
const cardId = `match-${group.id}-${md}-${idx}`;
const userLocalTimeStr = getUserLocalTime(match, venue);
const isFinished = match.status === 'FT';
let scoreDisplayHTML = `<span class="mc-vs">VS</span>`;
if (isFinished && match.score) {
const homeWinnerClass = 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');
scoreDisplayHTML = `
<div class="mc-score-wrap">
<span class="mc-score ${homeWinnerClass}">${match.score.home}</span>
<span class="mc-score-dash"></span>
<span class="mc-score ${awayWinnerClass}">${match.score.away}</span>
<span class="mc-status-badge">FT</span>
</div>
`;
}
let scorersHTML = '';
if (isFinished && match.scorers) {
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(', ') : '';
scorersHTML = `
<div class="match-scorers-panel">
<div class="scorers-team home-scorers">⚽ ${homeScorersList || '—'}</div>
<div class="scorers-divider"></div>
<div class="scorers-team away-scorers">⚽ ${awayScorersList || '—'}</div>
</div>
`;
}
return `
<div class="match-card" style="--match-color:${group.color}" id="${cardId}">
<div class="match-card ${isFinished ? 'match-finished' : ''}" style="--match-color:${group.color}" id="${cardId}">
<button class="match-summary" onclick="toggleMatch('${cardId}')" aria-expanded="false">
<div class="match-team-col">
<div class="match-team-col home-col">
<span class="mc-flag">${home ? home.flag : '🏴'}</span>
<span class="mc-name">${match.home}</span>
</div>
<div class="match-divider">
<span class="mc-vs">VS</span>
${scoreDisplayHTML}
<span class="mc-chevron"></span>
</div>
<div class="match-team-col">
<div class="match-team-col away-col">
<span class="mc-flag">${away ? away.flag : '🏴'}</span>
<span class="mc-name">${match.away}</span>
</div>
</button>
<div class="match-details-panel">
<div class="match-details-inner">
${scorersHTML}
<div class="match-detail-row"><span class="md-icon">📅</span><div class="md-text"><span class="md-label">Date</span><span class="md-value">${match.date}</span></div></div>
<div class="match-detail-row"><span class="md-icon">🕐</span><div class="md-text"><span class="md-label">Kick-off</span><span class="md-value">${match.time} local time${userLocalTimeStr ? ' / ' + userLocalTimeStr + ' your time' : ''}</span></div></div>
<div class="match-detail-row"><span class="md-icon">📍</span><div class="md-text"><span class="md-label">Venue</span><span class="md-value">${venue ? venue.stadium : match.venue}</span></div></div>
<div class="match-detail-row"><span class="md-icon">🏙️</span><div class="md-text"><span class="md-label">City</span><span class="md-value">${match.venue}${venue ? ', ' + venue.country : ''}</span></div></div>
<div class="match-detail-row"><span class="md-icon">👥</span><div class="md-text"><span class="md-label">Capacity</span><span class="md-value">${venue ? venue.capacity : '—'}</span></div></div>
<div class="simulator-section">
<div class="simulator-title">🎮 Simulate Match Score</div>
<div class="simulator-row">
<div class="sim-team">
<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 : ''}">
</div>
<span class="sim-vs"></span>
<div class="sim-team">
<input type="number" min="0" placeholder="0" class="sim-input" id="input-${cardId}-away" value="${isFinished ? match.score.away : ''}">
<span class="sim-flag">${away ? away.flag : ''}</span>
</div>
</div>
<div class="simulator-actions">
<button class="sim-btn sim-btn-save" onclick="saveMatchScore('${group.id}', ${md}, ${idx}, '${cardId}')">Save Score</button>
${isFinished ? `<button class="sim-btn sim-btn-clear" onclick="clearMatchScore('${group.id}', ${md}, ${idx}, '${cardId}')">Clear</button>` : ''}
</div>
</div>
</div>
</div>
</div>`;
@@ -154,6 +299,61 @@ function buildSquadHTML(group) {
}).join('');
}
function buildStandingsHTML(group) {
const standings = calculateStandings(group);
const rowsHTML = standings.map((item, idx) => {
const rank = idx + 1;
const rankClass = rank <= 2 ? 'rank-adv' : rank === 3 ? 'rank-pot' : 'rank-el';
const rowClass = rank <= 2 ? 'row-adv' : rank === 3 ? 'row-pot' : 'row-el';
return `
<tr class="${rowClass}">
<td class="col-pos"><span class="table-rank-num ${rankClass}">${rank}</span></td>
<td class="col-team">
<span class="table-flag">${item.flag}</span>
<span class="table-team-name">${item.name}</span>
${item.isHost ? '<span class="table-host-badge" title="Host">🏟️</span>' : ''}
</td>
<td class="col-stat">${item.played}</td>
<td class="col-stat font-w-600">${item.won}</td>
<td class="col-stat">${item.drawn}</td>
<td class="col-stat">${item.lost}</td>
<td class="col-stat text-hide-mobile">${item.gf}</td>
<td class="col-stat text-hide-mobile">${item.ga}</td>
<td class="col-stat font-w-600">${item.gd > 0 ? '+' + item.gd : item.gd}</td>
<td class="col-stat col-pts">${item.pts}</td>
</tr>
`;
}).join('');
return `
<div class="standings-wrapper">
<table class="standings-table">
<thead>
<tr>
<th class="col-pos">#</th>
<th class="col-team">Team</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>
${rowsHTML}
</tbody>
</table>
<div class="standings-legend">
<div class="legend-item"><span class="legend-dot dot-adv"></span> <span class="legend-text">Top 2 advance to Round of 32</span></div>
<div class="legend-item"><span class="legend-dot dot-pot"></span> <span class="legend-text">Best 3rd-place teams advance</span></div>
</div>
</div>
`;
}
// ── Modal ──────────────────────────────────────────────────────────────────
function openModal(group) {
if (!group || !group.matches || group.matches.length === 0) return;
@@ -176,12 +376,16 @@ function openModal(group) {
</div>
<div class="modal-tabs">
<button class="modal-tab active" id="tab-schedule" onclick="switchTab('schedule')">📅 Schedule</button>
<button class="modal-tab" id="tab-standings" onclick="switchTab('standings')">📈 Standings</button>
<button class="modal-tab" id="tab-squad" onclick="switchTab('squad')">👕 Squads</button>
</div>
<div class="modal-body" id="modal-body">
<div id="tab-content-schedule" class="tab-content active">
<div class="matchdays">${buildMatchdayHTML(group)}</div>
</div>
<div id="tab-content-standings" class="tab-content">
${buildStandingsHTML(group)}
</div>
<div id="tab-content-squad" class="tab-content">
${buildSquadHTML(group)}
</div>
@@ -211,6 +415,97 @@ function closeModal() {
}
}
// ── Simulation Handlers ────────────────────────────────────────────────────
window.saveMatchScore = function(groupId, matchday, matchIdx, cardId) {
const homeInput = document.getElementById(`input-${cardId}-home`);
const awayInput = document.getElementById(`input-${cardId}-away`);
if (!homeInput || !awayInput) return;
const homeVal = homeInput.value.trim();
const awayVal = awayInput.value.trim();
if (homeVal === '' || awayVal === '') {
alert('Please enter scores for both teams.');
return;
}
const homeScore = parseInt(homeVal, 10);
const awayScore = parseInt(awayVal, 10);
if (isNaN(homeScore) || isNaN(awayScore) || homeScore < 0 || awayScore < 0) {
alert('Scores must be non-negative integers.');
return;
}
const group = appGroups.find(g => g.id === groupId);
if (group) {
const dayMatches = group.matches.filter(m => m.matchday === matchday);
const match = dayMatches[matchIdx];
if (match) {
match.status = 'FT';
match.score = { home: homeScore, away: awayScore };
// Preserve or set scorers for Mexico vs South Africa if restored to 2 - 1
if (groupId === 'A' && matchday === 1 && matchIdx === 0 && homeScore === 2 && awayScore === 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;
}
saveState();
render();
const updatedGroup = appGroups.find(g => g.id === groupId);
const activeTab = document.querySelector('.modal-tab.active')?.id || 'tab-schedule';
const tabName = activeTab.replace('tab-', '');
openModal(updatedGroup);
switchTab(tabName);
}
}
};
window.clearMatchScore = function(groupId, matchday, matchIdx, cardId) {
const group = appGroups.find(g => g.id === groupId);
if (group) {
const dayMatches = group.matches.filter(m => m.matchday === matchday);
const match = dayMatches[matchIdx];
if (match) {
delete match.status;
delete match.score;
delete match.scorers;
saveState();
render();
const updatedGroup = appGroups.find(g => g.id === groupId);
const activeTab = document.querySelector('.modal-tab.active')?.id || 'tab-schedule';
const tabName = activeTab.replace('tab-', '');
openModal(updatedGroup);
switchTab(tabName);
}
}
};
window.resetAllScores = function() {
if (confirm('Are you sure you want to reset all simulated scores back to default?')) {
localStorage.removeItem('wc2026_groups');
loadState();
render();
closeModal();
animateCounters();
}
};
// ── Accordion toggle ───────────────────────────────────────────────────────
window.toggleMatch = function(cardId) {
const card = document.getElementById(cardId);
@@ -232,7 +527,7 @@ function render() {
const grid = document.getElementById('groups-grid');
if (!grid) return;
const filteredGroups = groups.map(group => {
const filteredGroups = appGroups.map(group => {
let filteredTeams = group.teams;
if (activeFilter !== 'all') {
filteredTeams = filteredTeams.filter(t => t.confederation === activeFilter);
@@ -264,10 +559,14 @@ function render() {
// ── Global click handler (set ONCE on document.body) ──────────────────────
function setupGlobalClick() {
document.body.addEventListener('click', (e) => {
// If click is on simulation controls or buttons inside modal, don't trigger group open
if (e.target.closest('.sim-btn') || e.target.closest('.sim-input') || e.target.closest('.modal-tabs') || e.target.closest('.modal-close') || e.target.closest('.theme-toggle-btn') || e.target.closest('.reset-simulation-btn')) {
return;
}
const card = e.target.closest('.group-card');
if (card) {
const groupId = card.dataset.group;
const group = groups.find(g => g.id === groupId);
const group = appGroups.find(g => g.id === groupId);
console.log('[WC2026] Card clicked, groupId:', groupId, 'found:', !!group);
openModal(group);
}
@@ -366,6 +665,7 @@ function buildShell() {
<button class="filter-btn" data-filter="CAF">🌍 CAF</button>
<button class="filter-btn" data-filter="CONCACAF">🌎 CONCACAF</button>
<button class="filter-btn" data-filter="OFC">🌊 OFC</button>
<button class="reset-simulation-btn" onclick="resetAllScores()" title="Reset all custom scores to defaults">🔄 Reset</button>
</nav>
</div>
</div>
+360
View File
@@ -808,6 +808,366 @@ body {
.modal-group-id { font-size: 36px; }
}
/* ── Reset Button in Header/Filter Bar ── */
.reset-simulation-btn {
background: var(--bg-element);
border: 1px dashed var(--border);
border-radius: 20px;
color: var(--text-muted);
cursor: pointer;
font-family: inherit; font-size: 12px; font-weight: 600;
padding: 6px 14px;
transition: all var(--transition);
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.reset-simulation-btn:hover {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
transform: translateY(-1px);
}
/* ── Group Card Ranks & Points ── */
.team-rank {
font-size: 11px;
font-weight: 800;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
margin-right: 4px;
flex-shrink: 0;
}
.team-rank.rank-adv {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.team-rank.rank-pot {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.team-rank.rank-el {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.team-pts-badge {
font-size: 11px;
font-weight: 800;
color: var(--gold);
background: rgba(255, 215, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
margin-left: auto;
letter-spacing: 0.5px;
}
/* ── Standings Table in Modal ── */
.standings-wrapper {
margin-bottom: 16px;
}
.standings-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 14px;
}
.standings-table th, .standings-table td {
padding: 12px 10px;
text-align: center;
border-bottom: 1px solid var(--border-light);
}
.standings-table th {
font-weight: 700;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
background: var(--bg-element);
}
.standings-table tbody tr {
transition: background var(--transition);
}
.standings-table tbody tr:hover {
background: var(--bg-element);
}
.standings-table td.col-team {
text-align: left;
display: flex;
align-items: center;
gap: 8px;
}
.table-flag {
font-size: 20px;
line-height: 1;
}
.table-team-name {
font-weight: 600;
color: var(--text-primary);
}
.table-host-badge {
font-size: 12px;
cursor: help;
}
.col-pos {
width: 40px;
}
.table-rank-num {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 800;
font-size: 12px;
}
.table-rank-num.rank-adv {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.table-rank-num.rank-pot {
background: rgba(245, 158, 11, 0.2);
color: #facc15;
}
.table-rank-num.rank-el {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.col-pts {
font-weight: 800;
color: var(--gold);
}
.font-w-600 {
font-weight: 600;
}
/* Row-based visual indicators */
.standings-table tbody tr.row-adv td.col-team {
border-left: 3px solid #22c55e;
}
.standings-table tbody tr.row-pot td.col-team {
border-left: 3px solid #f59e0b;
}
.standings-table tbody tr.row-el td.col-team {
border-left: 3px solid #ef4444;
}
.standings-legend {
display: flex;
gap: 20px;
margin-top: 18px;
padding: 10px 14px;
background: var(--bg-element);
border-radius: 8px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.legend-dot.dot-adv {
background: #22c55e;
}
.legend-dot.dot-pot {
background: #f59e0b;
}
.legend-text {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
/* Hide some columns on mobile */
@media (max-width: 640px) {
.text-hide-mobile {
display: none;
}
.standings-table th, .standings-table td {
padding: 10px 6px;
font-size: 13px;
}
.reset-simulation-btn {
margin-left: 0;
margin-top: 10px;
width: 100%;
justify-content: center;
}
}
/* ── Match Score Displays ── */
.mc-score-wrap {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-card);
padding: 4px 12px;
border-radius: 20px;
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
position: relative;
}
.mc-score {
font-size: 18px;
font-weight: 800;
line-height: 1;
}
.mc-score.mc-team-winner {
color: var(--gold);
}
.mc-score.mc-team-loser {
color: var(--text-muted);
}
.mc-score.mc-team-draw {
color: var(--text-primary);
}
.mc-score-dash {
font-size: 14px;
color: var(--text-faint);
font-weight: 700;
}
.mc-status-badge {
font-size: 9px;
font-weight: 900;
background: var(--border-hover);
color: var(--text-primary);
padding: 2px 6px;
border-radius: 10px;
margin-left: 4px;
text-transform: uppercase;
}
.match-finished {
background: color-mix(in srgb, var(--match-color, var(--gold)) 3%, var(--bg-element));
}
.match-finished .home-col .mc-name,
.match-finished .away-col .mc-name {
font-weight: 550;
}
/* Scorers list */
.match-scorers-panel {
display: flex;
justify-content: space-between;
padding: 10px 22px;
font-size: 12px;
color: var(--text-muted);
background: var(--bg-element);
border-bottom: 1px solid var(--border-light);
gap: 16px;
}
.scorers-team {
flex: 1;
font-weight: 500;
}
.home-scorers {
text-align: right;
}
.away-scorers {
text-align: left;
}
.scorers-divider {
width: 1px;
background: var(--border-light);
}
/* ── Score Simulator Panel ── */
.simulator-section {
padding: 16px 22px;
background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--match-color, var(--gold)) 5%, transparent));
border-top: 1px dashed var(--border-light);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.simulator-title {
font-size: 11px;
font-weight: 800;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--match-color, var(--gold));
}
.simulator-row {
display: flex;
align-items: center;
gap: 14px;
}
.sim-team {
display: flex;
align-items: center;
gap: 8px;
}
.sim-flag {
font-size: 24px;
}
.sim-input {
width: 60px;
height: 38px;
background: var(--bg-card);
border: 2px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
text-align: center;
font-family: inherit;
font-size: 16px;
font-weight: 800;
outline: none;
transition: all var(--transition);
}
.sim-input:focus {
border-color: var(--match-color, var(--gold));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--match-color, var(--gold)) 20%, transparent);
}
.sim-vs {
font-size: 18px;
font-weight: 700;
color: var(--text-faint);
}
.simulator-actions {
display: flex;
gap: 8px;
}
.sim-btn {
font-family: inherit;
font-size: 12px;
font-weight: 700;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
border: 1px solid transparent;
transition: all var(--transition);
}
.sim-btn-save {
background: var(--match-color, var(--gold));
color: #000000;
font-weight: 800;
}
.sim-btn-save:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.sim-btn-clear {
background: transparent;
border-color: var(--border-hover);
color: var(--text-secondary);
}
.sim-btn-clear:hover {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
@media (min-width: 1200px) {
.groups-grid { grid-template-columns: repeat(4, 1fr); }
}