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%;