This commit is contained in:
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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' },
|
||||||
|
|||||||
+591
-74
@@ -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();
|
|
||||||
setupFilters();
|
loadUserSimulations();
|
||||||
setupSearch();
|
|
||||||
setupScrollBehavior();
|
Promise.all([fetchScores(), fetchScorers()]).then(() => {
|
||||||
setupKeyboard();
|
appGroups = JSON.parse(JSON.stringify(groups));
|
||||||
animateCounters();
|
initLiveTicking();
|
||||||
|
setupViewTabs();
|
||||||
|
setupFilters();
|
||||||
|
setupSearch();
|
||||||
|
setupScrollBehavior();
|
||||||
|
setupKeyboard();
|
||||||
|
animateCounters();
|
||||||
|
});
|
||||||
|
|||||||
+155
@@ -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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user