diff --git a/src/main.js b/src/main.js index cd259f9..cd34240 100644 --- a/src/main.js +++ b/src/main.js @@ -48,33 +48,169 @@ function saveUserSimulations() { localStorage.setItem('wc2026_user_sims', JSON.stringify(userSimulations)); } +const apiTeamNameMap = { + 'Bosnia and Herzegovina': 'Bosnia & Herzegovina', + 'Ivory Coast': "Côte d'Ivoire", + 'Turkey': 'Türkiye', + 'Democratic Republic of the Congo': 'DR Congo' +}; + +function mapApiTeamName(name) { + return apiTeamNameMap[name] || name; +} + +function parseApiScorers(scorerStr) { + if (!scorerStr || scorerStr === 'null') return []; + // Clean characters: { } " “ ” + let cleaned = scorerStr.replace(/[{}"“”]/g, ''); + if (!cleaned.trim()) return []; + + // Split by comma + const parts = cleaned.split(','); + return parts.map(p => { + p = p.trim(); + // Match name followed by minute (e.g. "Santiago Giménez 24'") + const match = p.match(/(.+?)\s+(\d+)'?/); + if (match) { + return { + name: match[1].trim(), + min: parseInt(match[2], 10) + }; + } + return { name: p, min: 90 }; // fallback + }).filter(s => s.name); +} + +const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes in ms +const CACHE_KEY_SCORES = 'wc2026_scores_cache'; +const CACHE_KEY_SCORERS = 'wc2026_scorers_cache'; +const CACHE_KEY_TIMESTAMP = 'wc2026_sync_timestamp'; + +function updateSyncTooltip() { + const btn = document.getElementById('sync-scores-btn'); + if (btn) { + const cachedTime = localStorage.getItem(CACHE_KEY_TIMESTAMP); + const syncDate = cachedTime ? new Date(parseInt(cachedTime, 10)) : new Date(); + const timeStr = syncDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + btn.title = `Last synced: ${timeStr}. Sync live scores with server.`; + } +} + async function fetchScores() { + // Check if we have valid cached data + try { + const cachedTime = localStorage.getItem(CACHE_KEY_TIMESTAMP); + const cachedScores = localStorage.getItem(CACHE_KEY_SCORES); + const cachedScorers = localStorage.getItem(CACHE_KEY_SCORERS); + + if (cachedTime && cachedScores && cachedScorers) { + const age = Date.now() - parseInt(cachedTime, 10); + if (age < CACHE_DURATION) { + fetchedScores = JSON.parse(cachedScores); + fetchedScorers = JSON.parse(cachedScorers); + return; // Success, skip fetch + } + } + } catch (err) { + console.error('[WC2026] Error reading sync cache:', err); + } + + // Cache is missing or expired, fetch fresh data + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 25000); + const res = await fetch('https://worldcup26.ir/get/games', { signal: controller.signal }); + clearTimeout(timeoutId); + + if (res.ok) { + const data = await res.json(); + if (data && Array.isArray(data.games)) { + const translatedScores = []; + const translatedScorers = []; + + data.games.forEach(game => { + const groupId = game.group; + const matchday = parseInt(game.matchday, 10); + const home = mapApiTeamName(game.home_team_name_en); + const away = mapApiTeamName(game.away_team_name_en); + const homeScore = game.home_score !== null && game.home_score !== 'null' ? parseInt(game.home_score, 10) : null; + const awayScore = game.away_score !== null && game.away_score !== 'null' ? parseInt(game.away_score, 10) : null; + + if (groupId && !isNaN(matchday) && home && away && homeScore !== null && awayScore !== null && !isNaN(homeScore) && !isNaN(awayScore)) { + translatedScores.push({ + groupId, + matchday, + home, + away, + score: { home: homeScore, away: awayScore } + }); + + const homeScorersList = parseApiScorers(game.home_scorers); + const awayScorersList = parseApiScorers(game.away_scorers); + + if (homeScorersList.length > 0 || awayScorersList.length > 0) { + translatedScorers.push({ + groupId, + matchday, + home, + away, + scorers: { + home: homeScorersList, + away: awayScorersList + } + }); + } + } + }); + + fetchedScores = translatedScores; + fetchedScorers = translatedScorers; + + // Save to cache + try { + localStorage.setItem(CACHE_KEY_SCORES, JSON.stringify(fetchedScores)); + localStorage.setItem(CACHE_KEY_SCORERS, JSON.stringify(fetchedScorers)); + localStorage.setItem(CACHE_KEY_TIMESTAMP, Date.now().toString()); + } catch (cacheErr) { + console.error('[WC2026] Error writing sync cache:', cacheErr); + } + return; // Success! + } + } + } catch (e) { + // Bypassed online API console warning + } + + // Fallback to local scores 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); + console.error('[WC2026] Failed to fetch local baseline scores:', res.status); } } catch (e) { - console.error('[WC2026] Error fetching baseline scores:', e); + console.error('[WC2026] Error fetching local baseline scores:', e); } } async function fetchScorers() { + // If we already loaded scorers from the online API or cache, skip local fetch + if (fetchedScorers.length > 0 && fetchedScores.length > 0) { + return; + } + 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); + console.error('[WC2026] Failed to fetch local baseline scorers:', res.status); } } catch (e) { - console.error('[WC2026] Error fetching baseline scorers:', e); + console.error('[WC2026] Error fetching local baseline scorers:', e); } } @@ -711,6 +847,62 @@ window.resetAllScores = function() { } }; +window.triggerManualSync = async function() { + const btn = document.getElementById('sync-scores-btn'); + if (!btn || btn.classList.contains('syncing')) return; + + btn.classList.add('syncing'); + btn.disabled = true; + const icon = btn.querySelector('.sync-icon'); + const textNode = btn.querySelector('.sync-text'); + + if (icon) icon.classList.add('spin-anim'); + if (textNode) textNode.textContent = 'Syncing...'; + + try { + // Fetch both scores and scorers + await Promise.all([fetchScores(), fetchScorers()]); + + // Update local match states based on newly fetched baseline scores & time-based ticking + updateMatchStates(); + render(); + + // Success feedback + btn.classList.remove('syncing'); + btn.classList.add('success'); + if (textNode) textNode.textContent = 'Synced!'; + if (icon) { + icon.classList.remove('spin-anim'); + icon.textContent = '✅'; + } + + // Update tooltip with exact sync time + updateSyncTooltip(); + + setTimeout(() => { + btn.classList.remove('success'); + btn.disabled = false; + if (textNode) textNode.textContent = 'Sync'; + if (icon) icon.textContent = '🔄'; + }, 1500); + } catch (err) { + console.error('[WC2026] Manual sync failed:', err); + btn.classList.remove('syncing'); + btn.classList.add('error'); + if (textNode) textNode.textContent = 'Failed'; + if (icon) { + icon.classList.remove('spin-anim'); + icon.textContent = '❌'; + } + setTimeout(() => { + btn.classList.remove('error'); + btn.disabled = false; + if (textNode) textNode.textContent = 'Sync'; + if (icon) icon.textContent = '🔄'; + }, 2000); + } +}; + // ── Accordion toggle ─────────────────────────────────────────────────────── window.toggleMatch = function(cardId) { const card = document.getElementById(cardId); @@ -1138,7 +1330,8 @@ function buildShell() { - + + @@ -1216,9 +1409,14 @@ function initLiveTicking() { // Sync with server once every 60 seconds (prevents rate limit blocking) setInterval(async () => { - await fetchScores(); - updateMatchStates(); - render(); + try { + await Promise.all([fetchScores(), fetchScorers()]); + updateMatchStates(); + render(); + updateSyncTooltip(); + } catch (e) { + console.error('[WC2026] Background sync failed:', e); + } }, 60000); } @@ -1231,6 +1429,10 @@ loadUserSimulations(); Promise.all([fetchScores(), fetchScorers()]).then(() => { appGroups = JSON.parse(JSON.stringify(groups)); initLiveTicking(); + + // Set initial sync time in tooltip + updateSyncTooltip(); + setupViewTabs(); setupFilters(); setupSearch(); diff --git a/src/style.css b/src/style.css index 6d861ef..09c98c7 100644 --- a/src/style.css +++ b/src/style.css @@ -808,10 +808,10 @@ body { .modal-group-id { font-size: 36px; } } -/* ── Reset Button in Header/Filter Bar ── */ -.reset-simulation-btn { +/* ── Sync & Reset Buttons in Header/Filter Bar ── */ +.sync-scores-btn { background: var(--bg-element); - border: 1px dashed var(--border); + border: 1px solid var(--border); border-radius: 20px; color: var(--text-muted); cursor: pointer; @@ -823,6 +823,40 @@ body { align-items: center; gap: 6px; } +.sync-scores-btn:hover:not(:disabled) { + background: var(--bg-element-hover); + border-color: var(--gold); + color: var(--gold); + transform: translateY(-1px); +} +.sync-scores-btn:disabled { + cursor: not-allowed; + opacity: 0.7; +} +.sync-scores-btn.success { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.4); + color: #22c55e; +} +.sync-scores-btn.error { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.4); + color: #ef4444; +} + +.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); + 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); @@ -830,6 +864,15 @@ body { transform: translateY(-1px); } +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.spin-anim { + animation: spin 1s linear infinite; + display: inline-block; +} + /* ── Group Card Ranks & Points ── */ .team-rank { font-size: 11px; @@ -997,7 +1040,7 @@ body { padding: 10px 6px; font-size: 13px; } - .reset-simulation-btn { + .sync-scores-btn, .reset-simulation-btn { margin-left: 0; margin-top: 10px; width: 100%;