/* ============================================================ ETT STATS — app.js (rebuild curat) ============================================================ */ const CFG = window.CFG || {}; // TRANSLATIONS loaded from i18n.js let currentLang = "en"; const LANG_STORAGE_KEY = "ett:lang"; const SUPPORTED_LANGS = ["en", "de", "fr", "pt", "es", "sv", "pl", "it", "ro"]; function readSavedLang() { try { const raw = String(localStorage.getItem(LANG_STORAGE_KEY) || "").trim().toLowerCase(); return SUPPORTED_LANGS.includes(raw) ? raw : null; } catch (_) { return null; } } function saveLangPreference(lang) { try { const normalized = String(lang || "").trim().toLowerCase(); if (!SUPPORTED_LANGS.includes(normalized)) return; localStorage.setItem(LANG_STORAGE_KEY, normalized); } catch (_) {} } function t(key, ...args) { const val = TRANSLATIONS[currentLang]?.[key] ?? TRANSLATIONS.en[key] ?? key; return typeof val === "function" ? val(...args) : val; } function getDeepSearchTooltipText() { if (currentLang === "ro") { return "Daca jucatorul cautat nu apare in lista, apasa butonul cu lupa."; } return "If the player you are looking for is not in the list, press the magnifying-glass button."; } function updateDeepSearchTooltip() { const btn = document.getElementById("searchBtn"); if (!btn) return; if (window.innerWidth > CFG.MOBILE_BREAKPOINT_PX) { btn.setAttribute("data-tooltip", getDeepSearchTooltipText()); btn.setAttribute("data-tooltip-pos", "bottom-right"); } else { // Desktop-only hint: remove tooltip payload on mobile. btn.removeAttribute("data-tooltip"); btn.removeAttribute("data-tooltip-pos"); } } function currentExternalProfileId() { const id = String(currentPlayerId || currentPlayer?.id || "").trim(); return /^\d+$/.test(id) ? id : ""; } function updateExternalProfileButtons() { const elevenBtn = document.getElementById("openElevenBtn"); const clubhouseBtn = document.getElementById("openClubhouseBtn"); const pid = currentExternalProfileId(); const shouldShow = !!pid; if (elevenBtn) { elevenBtn.classList.toggle("hidden", !shouldShow); elevenBtn.setAttribute("data-tooltip", t("tip_open_profile_elevenvr")); } if (clubhouseBtn) { clubhouseBtn.classList.toggle("hidden", !shouldShow); clubhouseBtn.setAttribute("data-tooltip", t("tip_open_profile_clubhouse")); } } function openCurrentProfileOnElevenVR() { const pid = currentExternalProfileId(); if (!pid) return; window.open(`https://app.elevenvr.com/#/player/${encodeURIComponent(pid)}`, "_blank", "noopener"); } function openCurrentProfileOnClubhouse() { const pid = currentExternalProfileId(); if (!pid) return; window.open(`https://11clubhouse.com/${encodeURIComponent(pid)}/`, "_blank", "noopener"); } function applyTranslations() { document.querySelectorAll("[data-i18n]").forEach(el => { // Keep the exact total number in statTotalNote when full stats injected it as rich HTML. if (el.id === "statTotalNote" && el.querySelector(".stat-peak-num")) { const labelEl = el.querySelector(".stat-peak-label"); if (labelEl) labelEl.textContent = t("label_incl_unranked"); return; } el.textContent = t(el.dataset.i18n); }); if (searchInput) searchInput.placeholder = t("search_ph"); document.documentElement.lang = currentLang; // Actualizeaza optiunea activa in dropdown document.querySelectorAll(".lang-option").forEach(btn => { btn.classList.toggle("active", btn.dataset.lang === currentLang); }); // Actualizeaza butonul selected const flagMap = { en: "us", de: "de", fr: "fr", pt: "br", es: "es", sv: "se", pl: "pl", it: "it", ro: "ro" }; const labelMap = { en: "EN", de: "DE", fr: "FR", pt: "PT", es: "ES", sv: "SV", pl: "PL", it: "IT", ro: "RO" }; const flagEl = document.getElementById("langSelectedFlag"); const textEl = document.getElementById("langSelectedText"); if (flagEl) flagEl.src = `https://flagcdn.com/16x12/${flagMap[currentLang]}.png`; if (textEl) textEl.textContent = labelMap[currentLang]; const eloIcon = document.getElementById("eloIcon"); if (eloIcon) { const isOpen = document.getElementById("eloCollapse")?.classList.contains("open"); eloIcon.textContent = isOpen ? t("elo_hide") : t("elo_show"); } const hofIcon = document.getElementById("hofIcon"); if (hofIcon) { const isOpen = document.getElementById("hofCollapse")?.classList.contains("open"); hofIcon.textContent = isOpen ? t("hof_hide_cards") : t("hof_show_cards"); } const hofScope = document.getElementById("hofScope"); if (hofScope && hofScope.dataset.scope) { hofScope.textContent = t(hofScope.dataset.scope); } renderLandingRankedActive(); const hfaIso = document.getElementById("heroFirstActive")?.dataset?.firstActiveIso; if (hfaIso) paintHeroFirstActive(hfaIso); if (currentPlayer) paintHeroLastOnline(currentPlayer.attributes); updateEloDynamicTitle(); } function updateEloDynamicTitle(pointCount = eloTitlePointCount) { const chartWrap = document.querySelector("#eloCollapse .chart-wrap"); if (!chartWrap) return; let tagEl = document.getElementById("eloScopeTag"); if (!tagEl) { tagEl = document.createElement("div"); tagEl.id = "eloScopeTag"; chartWrap.appendChild(tagEl); } // Discreet label: no border, grey non-bold text (reapplied each update). tagEl.style.position = "absolute"; tagEl.style.left = "50%"; tagEl.style.bottom = "2px"; tagEl.style.transform = "translateX(-50%)"; tagEl.style.padding = "0"; tagEl.style.border = "none"; tagEl.style.borderRadius = "0"; tagEl.style.background = "transparent"; tagEl.style.boxShadow = "none"; tagEl.style.color = "#8a94a6"; tagEl.style.fontFamily = "var(--font-condensed)"; tagEl.style.fontSize = "10px"; tagEl.style.fontWeight = "400"; tagEl.style.letterSpacing = ".06em"; tagEl.style.textTransform = "uppercase"; tagEl.style.pointerEvents = "none"; tagEl.style.zIndex = "30"; const nRaw = Number(pointCount); if (!Number.isFinite(nRaw) || nRaw < 0) { tagEl.style.display = "none"; return; } const n = Math.max(0, Math.round(nRaw)); const cap = eloChartMaxPointsForViewport(); const fmt = (v) => Number(v).toLocaleString(currentLang === "en" ? "en-US" : undefined); const core = n >= cap ? t("scope_last_ranked_matches_n", fmt(cap)) : t("scope_all_ranked_matches_n", fmt(n)); tagEl.textContent = `- ${core} -`; tagEl.style.display = "inline-flex"; } function setLang(lang) { if (!TRANSLATIONS[lang] || lang === currentLang) return; currentLang = lang; saveLangPreference(lang); applyTranslations(); // Re-randez tara cu noua limba if (currentPlayer) { const code = currentPlayer.attributes?.["country-code"]; const el = document.getElementById("heroCountry"); if (el && code) el.innerHTML = `${countryFlag(code)} ${countryName(code)}`; } // Actualizeaza tooltipurile hero cu noua limba (Last online + badge: din applyTranslations via paintHeroLastOnline) if (currentPlayer) { applyStatTooltips(); const shareBtn = document.getElementById("shareBtn"); if (shareBtn && !shareBtn.classList.contains("copied")) shareBtn.setAttribute("data-tooltip", t("tip_share")); } updateExternalProfileButtons(); // Actualizeaza tooltipurile din tabelul de meciuri document.querySelectorAll(".match-cell-elo").forEach(el => el.setAttribute("data-tooltip-html", t("tip_opp_elo", `${escHtml(el.getAttribute("data-opp-name") || "?")}`))); document.querySelectorAll(".match-date").forEach(el => el.setAttribute("data-tooltip", t("tip_match_time"))); document.querySelectorAll(".match-id-tag").forEach(el => el.setAttribute("data-tooltip", t("tip_copy_match_id"))); document.querySelectorAll(".opp-name-text").forEach(el => el.setAttribute("data-tooltip", t("tip_matchup_history"))); document.querySelectorAll(".matchup-player-line").forEach(el => el.setAttribute("data-tooltip", t("tip_show_player_profile"))); if (matchOppContextMenuEl && !matchOppContextMenuEl.classList.contains("hidden")) { const activeOppId = String(matchOppContextMenuEl.dataset.oppId || ""); const h2hBtn = matchOppContextMenuEl.querySelector("#matchOppCtxH2hBtn"); const newTabBtn = matchOppContextMenuEl.querySelector("#matchOppCtxNewTabBtn"); const copyIdBtn = matchOppContextMenuEl.querySelector("#matchOppCtxCopyIdBtn"); const elevenBtn = matchOppContextMenuEl.querySelector("#matchOppCtxElevenBtn"); const clubhouseBtn = matchOppContextMenuEl.querySelector("#matchOppCtxClubhouseBtn"); if (h2hBtn) h2hBtn.textContent = t("tip_matchup_history"); if (newTabBtn) newTabBtn.textContent = t("tip_open_profile_new_tab"); if (copyIdBtn) copyIdBtn.textContent = t("tip_copy_player_id", activeOppId); if (elevenBtn) elevenBtn.textContent = t("tip_open_profile_elevenvr"); if (clubhouseBtn) clubhouseBtn.textContent = t("tip_open_profile_clubhouse"); } document.querySelectorAll(".opp-flag-wrap[data-country-code]").forEach(el => { const code = el.getAttribute("data-country-code") || ""; el.setAttribute("data-tooltip", escHtml(countryName(code))); }); document.querySelectorAll(".matchup-item-event[data-left-name]").forEach(el => { const leftName = el.getAttribute("data-left-name") || "?"; el.setAttribute("data-tooltip-html", t("tip_left_game_simple", `${escHtml(leftName)}`)); }); document.querySelectorAll(".match-waived[data-waived-name]").forEach(el => { const waivedName = el.getAttribute("data-waived-name") || "?"; el.setAttribute("data-tooltip-html", t("tip_waived", `${escHtml(waivedName)}`)); }); const _profileName = currentPlayer?.attributes?.["user-name"] || "—"; document.querySelectorAll(".opp-elo-diff").forEach(el => { const diff = parseInt(el.getAttribute("data-diff") || "0", 10); const oppN = el.getAttribute("data-opp-name") || "?"; const profN = el.getAttribute("data-prof-name") || _profileName; const boldOpp = `${escHtml(oppN)}`; const boldProf = `${escHtml(profN)}`; const diffTip = diff === 0 ? t("tip_elodiff_equal") : diff > 0 ? t("tip_elodiff_ahead", boldOpp, boldProf, diff) : t("tip_elodiff_ahead", boldProf, boldOpp, Math.abs(diff)); el.setAttribute("data-tooltip-html", diffTip); }); document.querySelectorAll(".match-score[data-score-prof]").forEach(el => { const prof = el.getAttribute("data-score-prof") || ""; const opp = el.getAttribute("data-score-opp") || ""; const extraName = el.getAttribute("data-score-extra-name") || ""; const extra = extraName ? t("tip_left_game_simple", `${escHtml(extraName)}`) : (el.getAttribute("data-score-extra") || ""); el.setAttribute("data-score-extra", extra); el.setAttribute("data-tooltip-html", `${prof} vs ${opp}
${t("tip_score")}${extra ? `
${extra}` : ""}`); }); document.querySelectorAll(".match-score-summary[data-score-extra]").forEach(el => { const baseTip = el.getAttribute("data-tooltip-html") || ""; const extraName = el.getAttribute("data-score-extra-name") || ""; const extra = extraName ? t("tip_left_game_simple", `${escHtml(extraName)}`) : (el.getAttribute("data-score-extra") || ""); if (extraName) el.setAttribute("data-score-extra", extra); if (!baseTip) return; if (!extra) { el.setAttribute("data-tooltip-html", baseTip); return; } const lastBr = baseTip.lastIndexOf("
"); const tipPrefix = lastBr > -1 ? baseTip.slice(0, lastBr) : baseTip; el.setAttribute("data-tooltip-html", `${tipPrefix}
${extra}`); }); // Re-randez HOF cu noua limba daca avem date if (lastHofStats && lastHofScope) renderHof(lastHofStats, lastHofScope); updateDeepSearchTooltip(); updateUrlHash(); } // ── STARE GLOBALA ───────────────────────────────────────── let currentPlayer = null; let currentPlayerId = null; // Bridge for the multi-account page (multiple.js): allows setting these // `let`-scoped globals from another script. No-op for the main app. window.__appBridge = window.__appBridge || {}; window.__appBridge.setPlayer = function(p, id) { currentPlayer = p || null; currentPlayerId = id != null ? String(id) : null; }; window.__appBridge.setMatchPaging = function(page, total, size) { if (Number.isFinite(page)) matchPage = page; if (Number.isFinite(total)) matchTotal = total; if (Number.isFinite(size)) matchPageSize = size; }; window.__appBridge.setMatchupSnapshot = function(matches, included, id) { matchupMatchesSnapshot = Array.isArray(matches) ? matches : []; matchupIncludedSnapshot = Array.isArray(included) ? included : []; matchupSnapshotPlayerId = id != null ? String(id) : null; }; window.__appBridge.setMultiAccounts = function(accounts) { multiAccountContexts = Array.isArray(accounts) ? accounts.map(a => ({ id: String(a?.id || ""), name: String(a?.name || ""), country: String(a?.country || ""), elo: a?.elo ?? null })).filter(a => a.id) : []; }; window.__appBridge.setHofPeak = function(peakObj, achievedMap) { if (peakObj && Number.isFinite(Number(peakObj.value))) { hofMaxElo = { value: Number(peakObj.value), date: peakObj.date || null }; } else { hofMaxElo = null; } if (achievedMap && typeof achievedMap === "object") { eloAchievedDateMap = achievedMap; } renderHofEloMax(); }; let eloChart = null; let eloData = null; const waivedTimelineByPlayer = Object.create(null); // { playerId: { exact:Set, minute:Set } } const firstActiveIsoByPlayer = Object.create(null); // { playerId: iso } const DEFAULT_PAGE_TITLE = "Eleven VR Stats Tracker | Meaningful Insights for Any Player"; function setPageTitleForProfile(profileName) { const name = String(profileName || "").trim(); document.title = name ? `${name} player profile on ETTSTATS.eu` : DEFAULT_PAGE_TITLE; } let eloBadgesTimer = null; let eloMarkerMatchesByPlayer = {}; // { playerId: [ranked matches...] } let eloMarkerIncludedByPlayer = {}; // { playerId: [included rows for marker matches] } let eloMarkerLoadPromiseByPlayer = {}; // { playerId: Promise } /** After Calculate full stats (server cache): `'full'` — ranked-match timeline; otherwise `'csv'` — elo-history-csv only. */ const eloHistoryTierByPlayer = Object.create(null); /** One-shot { matches, included } after client finished CFS fetch; consumed by loadEloHistory before server cache exists. */ const clientFullStatsFetchBundleByPlayer = Object.create(null); function stashClientFullStatsFetchBundle(pid, matches, included) { const id = String(pid || "").trim(); if (!/^\d+$/.test(id) || !Array.isArray(matches) || !matches.length) return; clientFullStatsFetchBundleByPlayer[id] = { matches, included: Array.isArray(included) ? included : [] }; } function clearClientFullStatsFetchBundles() { Object.keys(clientFullStatsFetchBundleByPlayer).forEach((k) => { delete clientFullStatsFetchBundleByPlayer[k]; }); } let eloLoadSeq = 0; // monotonic guard against stale/hung chart loads let eloTitlePointCount = null; // null => hide scope tag, number => dynamic ranked scope tag let matchPage = 1; let matchPageSize = CFG.MATCH_PAGE_SIZE; let matchTotal = 0; let matchPrefetch = null; // { data, included } prefetchat in background let matchPrefetchId = null; // id-ul jucatorului pentru care s-a prefetchat let lazyMatchPrefetchObserver = null; // prefetch page N+1 only when Load more enters viewport let matchHybridContext = null; // cache-first dataset pentru paginare locala (index profile) /** Pentru calea API (fara hybrid): toate paginile incarcate pana acum — folosit la totaluri pe zi in banner */ let matchBannerAggregate = null; // { playerId, matches: [], included: [] } function mergeIncludedDedupe(base, extra) { const map = new Map(); [...(base || []), ...(extra || [])].forEach(i => { const type = String(i?.type || ""); const iid = String(i?.id || ""); if (!type || !iid) return; const key = `${type}:${iid}`; if (!map.has(key)) map.set(key, i); }); return Array.from(map.values()); } function buildRoundsMapFromIncluded(included) { const roundsMap = {}; (included || []).forEach(item => { if (item.type === "rounds") { const mid = String(item.attributes["match-id"]); if (!roundsMap[mid]) roundsMap[mid] = []; roundsMap[mid].push(item.attributes); } }); return roundsMap; } function resetMatchBannerAggregate() { matchBannerAggregate = null; } function mergeIntoMatchBannerAggregate(playerId, matches, included) { const pid = String(playerId || ""); if (!pid) return; if (!matchBannerAggregate || matchBannerAggregate.playerId !== pid) { matchBannerAggregate = { playerId: pid, matches: [], included: [] }; } const seen = new Set(matchBannerAggregate.matches.map(m => String(m?.id || ""))); (matches || []).forEach(m => { const mid = String(m?.id || ""); if (!mid || seen.has(mid)) return; seen.add(mid); matchBannerAggregate.matches.push(m); }); matchBannerAggregate.included = mergeIncludedDedupe(matchBannerAggregate.included, included); } /** Inlocuieste interiorul .day-separator cu totaluri calculate din lista completa de meciuri a zilei */ function refreshDaySeparatorFromMatches(sepEl, iso, dayMatches, roundsMap, playerId) { if (!sepEl || !dayMatches || !dayMatches.length) return; const wrap = document.createElement("div"); wrap.innerHTML = daySeparatorHtmlStandalone(iso, dayMatches, roundsMap, playerId); const inner = wrap.firstElementChild; if (!inner) return; sepEl.innerHTML = inner.innerHTML; } function setApiDataWarning(visible, message = "") { const warningEl = document.getElementById("apiDataWarning"); if (!warningEl) return; if (!visible) { warningEl.classList.add("hidden"); warningEl.textContent = ""; return; } warningEl.textContent = String(message || "").trim(); warningEl.classList.remove("hidden"); } let searchTimer = null; let searchToken = 0; // incrementat la fiecare cautare — ignora raspunsurile vechi let searchPool = {}; // { id: playerObject } — rezultate acumulate din requesturi succesive let searchPoolQuery = ""; // ultimul query pentru care pool-ul e valid let hofFullLoading = false; let hofMaxElo = null; let lastRankedMatchDate = null; // data celui mai recent meci ranked — pentru MAX ELO card let eloAchievedDateMap = {}; // { eloValue(int) -> date } — data la care a fost atins fiecare nivel ELO let lastHofStats = null; // ultimele stats HOF calculate let lastHofScope = null; // ultimul scope HOF randat let currentHofStats = null; let currentHofScopeKey = null; let rankedPopulationTotal = null; let rankedPopulationPromise = null; let profileBackStack = []; let suppressProfileBackPush = false; let profileExternalBackUrl = ""; // Snapshot local de meciuri pentru H2H (evita limitarile endpoint-ului /matchup) let matchupMatchesSnapshot = []; let matchupIncludedSnapshot = []; let matchupSnapshotPlayerId = null; // In-session cache pentru endpoint-ul /matchup — evita re-fetching complet la redeschidere const matchupPairCache = {}; // key: "meId:oppId" → { data: [...], included: [...], maxId: N } let multiAccountContexts = []; let reopenLocalRivalsOnMatchupClose = false; const localRivalsLastOnlineCache = {}; const LOCAL_RIVALS_LAST_ONLINE_STORAGE_KEY = "ettcache:local_rivals:last_online:v1"; const LOCAL_RIVALS_LAST_ONLINE_TTL_MS = 12 * 60 * 60 * 1000; // 12h let localRivalsLastOnlineHydrated = false; let localRivalsModalState = { key: "elo", dir: "desc", onlyActive: false }; let signatureVictoryRowsData = []; let signatureVictoryModalState = { rankedOnly: false }; let underdogStingRowsData = []; let underdogStingModalState = { rankedOnly: false }; let biggestComebackRowsData = []; let mostPlayedOppRowsAll = []; let mostPlayedOppModalState = { rankedOnly: false }; let mostPlayedOppRowsData = []; let longestMatchRowsData = []; let longestMatchModalState = { rankedOnly: false, sortKey: "points", sortDir: "desc" }; let fastFuriousRowsData = []; let fastFuriousModalState = { rankedOnly: false, sortKey: "points", sortDir: "asc" }; let mostActiveDayRowsData = []; let bestDayEverRowsData = []; let mostActiveDayModalState = { sortKey: "playtime", sortDir: "desc" }; let bestDayEverModalState = { sortKey: "net", sortDir: "desc" }; let dieHardRowsData = []; let favVictimRowsData = []; let longestRivalryRowsData = []; // ── ELEMENTE DOM ────────────────────────────────────────── const searchInput = document.getElementById("searchInput"); const searchResults = document.getElementById("searchResults"); const landing = document.getElementById("landing"); const profileSection = document.getElementById("profileSection"); // ── COUNTRY NAMES ───────────────────────────────────────── function countryName(code) { if (!code || code === "Earth" || code === "none" || code === "null") return "International"; try { const names = new Intl.DisplayNames([currentLang], { type: "region" }); return names.of(code.toUpperCase()) || code.toUpperCase(); } catch(e) { return code.toUpperCase(); } } function countryFlag(code) { if (!code || code === "Earth" || code === "none" || code === "null") { return ``; } const lower = code.toLowerCase(); return ``; } // ── UTILITARE ───────────────────────────────────────────── // ── CACHE SYSTEM ────────────────────────────────────────── const CACHE_TTL = { profile: CFG.CACHE_TTL_PROFILE_MIN * 60 * 1000, matches: CFG.CACHE_TTL_MATCHES_MIN * 60 * 1000, elo_history: CFG.CACHE_TTL_ELO_HISTORY_MIN * 60 * 1000, hof_full: CFG.CACHE_TTL_HOF_FULL_MIN * 60 * 1000, leaderboard: CFG.CACHE_TTL_LEADERBOARD_MIN * 60 * 1000, }; const CACHE_ONLINE_THRESHOLD = CFG.ONLINE_ACTIVE_THRESHOLD_MIN * 60 * 1000; function cacheKey(path) { return "ettcache:" + path; } function cacheGet(path) { try { const raw = localStorage.getItem(cacheKey(path)); if (!raw) return null; const { data, expires } = JSON.parse(raw); if (Date.now() > expires) { localStorage.removeItem(cacheKey(path)); return null; } return data; } catch(e) { return null; } } function cacheGetAny(path) { try { const raw = localStorage.getItem(cacheKey(path)); if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return null; return parsed.data ?? null; } catch(e) { return null; } } function isLikelyApiErrorPayload(data) { if (!data || typeof data !== "object") return true; if (Array.isArray(data.errors) && data.errors.length) return true; if (typeof data.error === "string" && data.error.trim()) return true; if (typeof data.message === "string" && /unauthorized|forbidden|invalid api|api key/i.test(data.message)) return true; return false; } function cacheSet(path, data, ttl) { try { localStorage.setItem(cacheKey(path), JSON.stringify({ data, expires: Date.now() + ttl, saved: Date.now() })); } catch(e) { cacheClean(); try { localStorage.setItem(cacheKey(path), JSON.stringify({ data, expires: Date.now() + ttl, saved: Date.now() })); } catch(e2) { /* ignoram */ } } } function cacheClean() { const toDelete = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !key.startsWith("ettcache:")) continue; try { const { expires } = JSON.parse(localStorage.getItem(key)); if (Date.now() > expires) toDelete.push(key); } catch(e) { toDelete.push(key); } } toDelete.forEach(k => localStorage.removeItem(k)); } function hydrateLocalRivalsLastOnlineCache() { if (localRivalsLastOnlineHydrated) return; localRivalsLastOnlineHydrated = true; try { const raw = localStorage.getItem(LOCAL_RIVALS_LAST_ONLINE_STORAGE_KEY); if (!raw) return; const parsed = JSON.parse(raw); const now = Date.now(); const entries = parsed && typeof parsed === "object" && parsed.entries && typeof parsed.entries === "object" ? parsed.entries : {}; let changed = false; Object.entries(entries).forEach(([id, rec]) => { if (!/^\d+$/.test(String(id || ""))) { changed = true; return; } const iso = String(rec?.lastOnline || "").trim(); const savedAt = Number(rec?.savedAt || 0); if (!iso || !Number.isFinite(savedAt) || (now - savedAt) > LOCAL_RIVALS_LAST_ONLINE_TTL_MS) { delete entries[id]; changed = true; return; } localRivalsLastOnlineCache[id] = iso; }); if (changed) { localStorage.setItem(LOCAL_RIVALS_LAST_ONLINE_STORAGE_KEY, JSON.stringify({ schema: 1, entries })); } } catch (_) {} } function persistLocalRivalsLastOnlineEntries(entriesById) { if (!entriesById || typeof entriesById !== "object") return; try { const now = Date.now(); const raw = localStorage.getItem(LOCAL_RIVALS_LAST_ONLINE_STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : null; const entries = parsed && typeof parsed === "object" && parsed.entries && typeof parsed.entries === "object" ? parsed.entries : {}; // Prune expired on write. Object.keys(entries).forEach(id => { const savedAt = Number(entries[id]?.savedAt || 0); if (!Number.isFinite(savedAt) || (now - savedAt) > LOCAL_RIVALS_LAST_ONLINE_TTL_MS) { delete entries[id]; } }); Object.entries(entriesById).forEach(([id, iso]) => { const sid = String(id || ""); const siso = String(iso || "").trim(); if (!/^\d+$/.test(sid) || !siso) return; entries[sid] = { lastOnline: siso, savedAt: now }; }); localStorage.setItem(LOCAL_RIVALS_LAST_ONLINE_STORAGE_KEY, JSON.stringify({ schema: 1, entries })); } catch (_) {} } function isPlayerActive(lastOnlineIso) { if (!lastOnlineIso) return false; return (Date.now() - new Date(lastOnlineIso).getTime()) <= CACHE_ONLINE_THRESHOLD; } function ttlForPath(path) { if (path.includes("/leaderboards")) return CACHE_TTL.leaderboard; if (path.includes("/elo-history")) return CACHE_TTL.elo_history; if (path.includes("/matches")) return CACHE_TTL.matches; if (path.match(/\/accounts\/\d+$/)) return CACHE_TTL.profile; return CACHE_TTL.matches; } function apiPathOnly(pathOrUrl) { const raw = String(pathOrUrl || "").trim(); if (!raw) return ""; if (/^https?:\/\//i.test(raw)) { try { const u = new URL(raw); return `${u.pathname || ""}${u.search || ""}`; } catch (_) { return ""; } } return raw; } function isLocalDevHost() { try { const h = String(location.hostname || "").toLowerCase(); return h === "localhost" || h === "127.0.0.1" || h === "[::1]"; } catch (_) { return false; } } function apiViaProxy(pathOrUrl, timeoutSec = 15) { const path = apiPathOnly(pathOrUrl); if (!path) return Promise.reject(new Error("Invalid API path")); return fetch("api-proxy.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, timeout: Number(timeoutSec) || 15 }) }) .then(async r => { const payload = await r.json().catch(() => null); const data = payload?.data; if (!r.ok || !payload?.ok || !data || isLikelyApiErrorPayload(data)) { if (isLocalDevHost()) { console.warn("[api-proxy]", path, { http: r.status, payload }); } throw new Error(payload?.error || `API proxy ${r.status || "error"}`); } return data; }); } function api(path, lastOnline) { const staleSnapshot = cacheGetAny(path); // Nu cache-uim: paginile de full statistics (page[size]=25) si paginile >1 de matches const isFullStats = path.includes(`page[size]=${CFG.HOF_QUICK_PAGE_SIZE}`) || path.includes(`page%5Bsize%5D=${CFG.HOF_QUICK_PAGE_SIZE}`); const isMatchesPage2Plus = path.includes("/matches") && !path.includes("page[number]=1&") && !path.includes("page[number]=1&page[size]=1"); if (isFullStats || isMatchesPage2Plus) { return apiViaProxy(path, 20) .catch(err => { if (staleSnapshot && !isLikelyApiErrorPayload(staleSnapshot)) return staleSnapshot; throw err; }); } // Nu facem cache daca jucatorul e activ (last-online <= 15 min) // pentru profil si meciuri (nu elo-history) const skipCache = lastOnline && isPlayerActive(lastOnline) && !path.includes("/elo-history"); if (!skipCache) { const cached = cacheGet(path); if (cached && !isLikelyApiErrorPayload(cached)) return Promise.resolve(cached); } return apiViaProxy(path, 15) .then(data => { if (!skipCache && !isLikelyApiErrorPayload(data)) cacheSet(path, data, ttlForPath(path)); return data; }) .catch(err => { if (staleSnapshot && !isLikelyApiErrorPayload(staleSnapshot)) return staleSnapshot; throw err; }); } async function readFullStatsCacheForProfile(playerId) { const pid = String(playerId || "").trim(); try { const r = await fetch("full-stats-cache-read.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: String(playerId || "") }) }); if (!r.ok) return { hasCache: false, matches: [], included: [], matchCount: 0 }; const json = await r.json().catch(() => null); if (!json?.ok || !json?.hasCache) { return { hasCache: false, matches: [], included: [], matchCount: 0 }; } const waivedExact = new Set(); const waivedMinute = new Set(); const toMinute = (iso) => { const d = new Date(iso); if (!Number.isFinite(d.getTime())) return ""; const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); const hh = String(d.getUTCHours()).padStart(2, "0"); const mm = String(d.getUTCMinutes()).padStart(2, "0"); return `${y}-${m}-${day} ${hh}:${mm}`; }; const waivedRoundsMap = buildRoundsMapFromIncluded(Array.isArray(json.included) ? json.included : []); (Array.isArray(json.matches) ? json.matches : []).forEach((m) => { const a = m?.attributes || {}; const ts = String(a["created-at"] || "").trim(); if (!ts) return; const isWaived = isWaivedByRoundsOrRaw(m, waivedRoundsMap); if (!isWaived) return; waivedExact.add(ts); const mk = toMinute(ts); if (mk) waivedMinute.add(mk); }); if (pid) waivedTimelineByPlayer[pid] = { exact: waivedExact, minute: waivedMinute }; return { hasCache: true, matches: Array.isArray(json.matches) ? json.matches : [], included: Array.isArray(json.included) ? json.included : [], matchCount: Number.isFinite(Number(json.matchCount)) ? Number(json.matchCount) : 0 }; } catch (_) { return { hasCache: false, matches: [], included: [], matchCount: 0 }; } } function buildProfileFromFullStatsCache(playerId, matches) { const pid = String(playerId || "").trim(); if (!pid || !Array.isArray(matches) || !matches.length) return null; let picked = null; const cap = Math.min(matches.length, 600); for (let i = 0; i < cap; i++) { const a = matches[i]?.attributes || {}; const teams = [] .concat(Array.isArray(a["home-team"]) ? a["home-team"] : []) .concat(Array.isArray(a["away-team"]) ? a["away-team"] : []); for (const member of teams) { if (String(member?.id || "") !== pid) continue; const rank = Number(member?.Rank); const elo = Number(member?.ELO); const score = (Number.isFinite(rank) && rank > 0 ? (100000 - rank) : 0) + (Number.isFinite(elo) ? elo : 0); if (!picked || score > picked.score) { picked = { member, score }; } } if (picked && Number.isFinite(Number(picked.member?.Rank)) && Number(picked.member?.Rank) > 0) break; } if (!picked?.member) return null; const m = picked.member; const wins = Number.isFinite(Number(m.Wins)) ? Number(m.Wins) : 0; const losses = Number.isFinite(Number(m.Losses)) ? Number(m.Losses) : 0; const rank = Number.isFinite(Number(m.Rank)) ? Number(m.Rank) : 0; const elo = Number.isFinite(Number(m.ELO)) ? Number(m.ELO) : null; return { id: pid, type: "accounts", attributes: { "user-name": String(m.UserName || `Player ${pid}`), "country-code": String(m["country-code"] || "").toUpperCase(), "last-online": m.LastOnline || null, wins, losses, rank, elo } }; } // Cache separat pentru HOF full (obiectul calculat, nu datele brute) const HOF_CACHE_SCHEMA = 12; // bump cand schimbam structura stats-ului calculat function hofCacheKey(id) { return `ettcache:hof_full:v${HOF_CACHE_SCHEMA}:${id}`; } function hofCacheGet(id) { try { const raw = localStorage.getItem(hofCacheKey(id)); if (!raw) return null; const { data, expires } = JSON.parse(raw); if (Date.now() > expires) { localStorage.removeItem(hofCacheKey(id)); return null; } return data; } catch(e) { return null; } } function hofCacheSet(id, data) { try { localStorage.setItem(hofCacheKey(id), JSON.stringify({ data, expires: Date.now() + CACHE_TTL.hof_full, saved: Date.now() })); } catch(e) { cacheClean(); } } // Curata cache-ul expirat la pornire cacheClean(); function formatDate(iso) { if (!iso) return "—"; const d = new Date(iso); return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); } function localeForCurrentLang() { const map = { en: "en-GB", de: "de-DE", fr: "fr-FR", pt: "pt-PT", es: "es-ES", sv: "sv-SE", pl: "pl-PL", it: "it-IT", ro: "ro-RO" }; return map[currentLang] || "en-GB"; } function formatDateLocalizedWithTextMonth(iso) { if (!iso) return "—"; const d = new Date(iso); return d.toLocaleDateString(localeForCurrentLang(), { day: "2-digit", month: "short", year: "numeric" }); } function isMobileViewport() { return window.innerWidth <= (typeof CFG !== "undefined" && CFG.MOBILE_BREAKPOINT_PX ? CFG.MOBILE_BREAKPOINT_PX : 640); } function formatDateModalCell(dateStr) { if (!isMobileViewport()) return escHtml(dateStr || "—"); const m = String(dateStr || "").match(/^(.+?)\s+(\d{4})$/); if (!m) return escHtml(dateStr || "—"); return `${escHtml(m[1])}${m[2]}`; } function hofDateOwnerCell(dateLabel, row) { const ownerName = String(row?.ownerName || "").trim(); const ownerCountry = String(row?.ownerCountry || "").trim(); const ownerLine = ownerName ? `
${countryFlag(ownerCountry)}${escHtml(ownerName)}
` : ""; return `${formatDateModalCell(dateLabel)}${ownerLine}`; } /** * ELO change for one match in HoF modals (+green / −red, em dash if none). * @param {{ asLoss?: boolean }} [opts] — If true (Underdog Sting: losses), API magnitude is shown as negative points lost. */ function formatModalMatchEloChangeHtml(eloChange, opts = {}) { const asLoss = !!opts.asLoss; let n = Number(eloChange); if (!Number.isFinite(n) || n === 0) { return ``; } if (asLoss) n = -Math.abs(n); const rounded = Math.round(n); if (rounded > 0) { return `+${rounded}`; } return `${rounded}`; } function hofAccountCell(row) { const accounts = Array.isArray(row?.accounts) ? row.accounts : []; const normalized = accounts .map(a => ({ name: String(a?.name || "").trim(), country: String(a?.country || "").trim() })) .filter(a => a.name); if (!normalized.length) return "—"; return normalized.map(a => `
${countryFlag(a.country)}${escHtml(a.name)}
`).join(""); } function formatDateRange(startIso, endIso) { if (!startIso) return "—"; const start = new Date(startIso); if (!endIso || startIso.slice(0, 10) === endIso.slice(0, 10)) { return start.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); } const end = new Date(endIso); const startDay = start.getDate(); const endDay = end.getDate(); const mon = end.toLocaleDateString("en-GB", { month: "short" }); const yr = end.getFullYear(); return `${startDay}–${endDay} ${mon} ${yr}`; } function fmtClockFromMinutes(totalMinutes) { const mins = ((Math.round(totalMinutes) % 1440) + 1440) % 1440; const hh = String(Math.floor(mins / 60)).padStart(2, "0"); const mm = String(mins % 60).padStart(2, "0"); return `${hh}:${mm}`; } function getLocalTimezoneLabel() { try { const offsetMinutes = -new Date().getTimezoneOffset(); const sign = offsetMinutes >= 0 ? "+" : "-"; const abs = Math.abs(offsetMinutes); const hh = String(Math.floor(abs / 60)).padStart(2, "0"); const mm = String(abs % 60).padStart(2, "0"); const gmt = `GMT${sign}${hh}:${mm}`; const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; return zone ? `${gmt} (${zone})` : gmt; } catch (_) { return "GMT"; } } function getLocalTimezoneShortLabel() { try { const offsetMinutes = -new Date().getTimezoneOffset(); const sign = offsetMinutes >= 0 ? "+" : "-"; const abs = Math.abs(offsetMinutes); const hhNum = Math.floor(abs / 60); const mmNum = abs % 60; if (mmNum === 0) return `GMT${sign}${hhNum}`; return `GMT${sign}${hhNum}:${String(mmNum).padStart(2, "0")}`; } catch (_) { return "GMT"; } } function circularMeanMinutes(values) { if (!Array.isArray(values) || !values.length) return 0; const TAU = Math.PI * 2; let sumX = 0; let sumY = 0; values.forEach(v => { const mins = ((Number(v) % 1440) + 1440) % 1440; const ang = (mins / 1440) * TAU; sumX += Math.cos(ang); sumY += Math.sin(ang); }); if (Math.abs(sumX) < 1e-9 && Math.abs(sumY) < 1e-9) { return values.reduce((a, b) => a + Number(b || 0), 0) / values.length; } let ang = Math.atan2(sumY, sumX); if (ang < 0) ang += TAU; return (ang / TAU) * 1440; } function formatDateTime(iso) { if (!iso) return "—"; const d = new Date(iso); const date = d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); return `${date}, ${h}:${m}`; } function timeAgo(iso) { if (!iso) return ""; const diff = Date.now() - new Date(iso).getTime(); const m = Math.floor(diff / 60000); const h = Math.floor(m / 60); const d = Math.floor(h / 24); const maxRel = Number(CFG.LAST_ONLINE_RELATIVE_MAX_DAYS ?? 90); if (Number.isFinite(maxRel) && d > maxRel) { const when = new Date(iso); const monthYear = when.toLocaleDateString(localeForCurrentLang(), { month: "short", year: "numeric" }); return t("last_online_month_year", monthYear); } return t("last_online", d, h, m); } /** Badge activitate lângă „Last online” pe profil — aceleași clase / i18n / tooltip ca în modalul Local Rivals. */ function inactivityBadge(iso) { if (!iso) return ""; const activity = inactivityMeta(iso); if (activity.className === "active-account") return ""; const label = escHtml(activity.label); const tip = escHtml(inactivityTooltip(activity.className)); return `${label}`; } function inactivityMeta(iso) { if (!iso) return { className: "active-account", label: t("activity_active_account") }; const days = Math.floor((Date.now() - new Date(iso).getTime()) / (24 * 60 * 60 * 1000)); if (!Number.isFinite(days) || days < CFG.INACTIVITY_WARN_DAYS) return { className: "active-account", label: t("activity_active_account") }; if (days >= CFG.INACTIVITY_FULL_DAYS) return { className: "dormant", label: t("activity_dormant_account") }; if (days >= CFG.INACTIVITY_SEMI_DAYS) return { className: "inactive-account", label: t("activity_inactive_account") }; return { className: "inactive-lately", label: t("activity_inactive_lately") }; } function inactivityTooltip(metaClass) { if (metaClass === "active-account") return t("activity_tip_active", CFG.INACTIVITY_WARN_DAYS); if (metaClass === "inactive-lately") return t("activity_tip_inactive_lately", CFG.INACTIVITY_WARN_DAYS, CFG.INACTIVITY_SEMI_DAYS - 1); if (metaClass === "inactive-account") return t("activity_tip_inactive", CFG.INACTIVITY_SEMI_DAYS, CFG.INACTIVITY_FULL_DAYS - 1); if (metaClass === "dormant") return t("activity_tip_dormant", CFG.INACTIVITY_FULL_DAYS); return t("activity_tip_unknown"); } function paintHeroLastOnline(attributes) { const el = document.getElementById("heroOnline"); if (!el || !attributes) return; const lastOnlineLabel = timeAgo(attributes["last-online"]); const onlineBadge = inactivityBadge(attributes["last-online"]); el.innerHTML = `${escHtml(lastOnlineLabel)}${onlineBadge ? ` ${onlineBadge}` : ""}`; } function winRate(wins, losses) { const total = wins + losses; if (total === 0) return "N/A"; return ((wins / total) * 100).toFixed(1) + "%"; } function dayKey(iso) { return iso ? iso.slice(0, 10) : "unknown"; } function escHtml(str) { return String(str) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // Estimeaza durata reala a unui meci (ms) folosind timestamps de la rundele (seturile) sale. // Problema: created-at al unui set = momentul START al acelui set, nu END. // Deci ultima durata de set se pierde cu metoda naiva (lastRound.start - match.start). // Solutie: calculam secunde/punct din seturile cunoscute si estimam durata ultimului set // proportional cu numarul de puncte jucate in el. function estimateMatchDurMs(rounds, matchStartMs) { if (!rounds || rounds.length === 0) return 0; const sorted = [...rounds].sort((a, b) => new Date(a["created-at"]).getTime() - new Date(b["created-at"]).getTime() ); const lastRoundTs = new Date(sorted[sorted.length - 1]["created-at"]).getTime(); const knownDurMs = lastRoundTs - matchStartMs; if (knownDurMs <= 0) return 0; if (sorted.length === 1) return knownDurMs; // un singur set — nu putem estima, returnam ce avem // Puncte din seturile cunoscute (toate mai putin ultimul) const knownPts = sorted.slice(0, -1).reduce((s, r) => s + (r["home-score"] || 0) + (r["away-score"] || 0), 0); const lastPts = (sorted[sorted.length - 1]["home-score"] || 0) + (sorted[sorted.length - 1]["away-score"] || 0); if (knownPts <= 0) return knownDurMs; const msPerPt = knownDurMs / knownPts; return Math.round(knownDurMs + lastPts * msPerPt); } // Un set e complet daca castigatorul are cel putin 11 puncte si avantaj de minim 2 function setIsComplete(homeScore, awayScore) { const maxS = Math.max(homeScore, awayScore); const minS = Math.min(homeScore, awayScore); return maxS >= 11 && (maxS - minS) >= 2; } function isWaivedByRoundsOrRaw(match, roundsMap = null) { const a = match?.attributes || {}; const mid = String(match?.id || ""); const rounds = roundsMap && mid && Array.isArray(roundsMap[mid]) ? roundsMap[mid] : []; if (rounds.length) { let totalPts = 0; let homeSets = 0; let awaySets = 0; rounds.forEach((r) => { const hs = Number(r?.["home-score"] || 0); const as = Number(r?.["away-score"] || 0); totalPts += hs + as; if (!setIsComplete(hs, as)) return; if (hs > as) homeSets++; else awaySets++; }); if (totalPts > 0 || homeSets > 0 || awaySets > 0) return false; } // Fallback pe scorul brut doar daca scorurile exista explicit in payload. // Meciurile vechi ranked pot avea home/away-score lipsa; interpretarea lor ca 0-0 // ar genera false positives (waived) si ar elimina puncte valide din grafic. if (a["home-score"] == null || a["away-score"] == null) return false; const hsRaw = Number(a["home-score"] || 0); const asRaw = Number(a["away-score"] || 0); return hsRaw === 0 && asRaw === 0; } // ── URL HASH ────────────────────────────────────────────── function updateUrlHash() { const ls = currentLang !== "en" ? "/" + currentLang : ""; if (currentPlayerId) { history.replaceState(null, "", "#" + currentPlayerId + ls); } else { history.replaceState(null, "", ls ? "#" + currentLang : window.location.pathname); } } function setUrlId(id) { const ls = currentLang !== "en" ? "/" + currentLang : ""; history.replaceState(null, "", "#" + id + ls); } function clearUrlId() { const ls = currentLang !== "en" ? "#" + currentLang : window.location.pathname; history.replaceState(null, "", ls); } function parseHash() { const raw = window.location.hash.replace("#", "").trim(); const parts = raw.split("/"); let id = null, lang = null; parts.forEach(p => { if (/^\d+$/.test(p)) id = p; if (SUPPORTED_LANGS.includes(p)) lang = p; }); return { id, lang }; } function checkUrlOnLoad() { try { const params = new URLSearchParams(window.location.search || ""); const backTo = params.get("back_to"); profileExternalBackUrl = backTo ? decodeURIComponent(backTo) : ""; } catch (_) { profileExternalBackUrl = ""; } const { id, lang } = parseHash(); const savedLang = readSavedLang(); if (lang) { currentLang = lang; saveLangPreference(lang); } else if (savedLang) { currentLang = savedLang; } applyTranslations(); if (id) { suppressProfileBackPush = true; loadPlayer(id).finally(() => { suppressProfileBackPush = false; updateProfileBackButton(); }); } else { document.title = DEFAULT_PAGE_TITLE; updateProfileBackButton(); } } window.addEventListener("popstate", () => { const { id, lang } = parseHash(); if (lang && lang !== currentLang) { currentLang = lang; applyTranslations(); } if (id) { suppressProfileBackPush = true; loadPlayer(id).finally(() => { suppressProfileBackPush = false; updateProfileBackButton(); }); } else { profileSection.classList.add("hidden"); landing.classList.remove("hidden"); if (searchInput) searchInput.value = ""; currentPlayerId = null; document.title = DEFAULT_PAGE_TITLE; updateProfileBackButton(); } }); // ── TOGGLE GRAFIC ELO ──────────────────────────────────── function toggleElo() { const collapse = document.getElementById("eloCollapse"); const icon = document.getElementById("eloIcon"); const loading = document.getElementById("eloLoading"); const isOpen = collapse.classList.contains("open"); if (isOpen) { collapse.classList.remove("open"); icon.textContent = t("elo_show"); } else { collapse.classList.add("open"); icon.textContent = t("elo_hide"); if (loading) { loading.textContent = t("elo_loading"); loading.style.display = "flex"; } const openSeq = eloLoadSeq; setTimeout(() => { const stillOpen = document.getElementById("eloCollapse")?.classList?.contains("open"); if (!stillOpen) return; if (openSeq !== eloLoadSeq) return; if (eloChart) return; const el = document.getElementById("eloLoading"); if (eloData) { try { if (el) el.style.display = "none"; renderEloChart(eloData); return; } catch (_) {} } if (el) { el.textContent = t("elo_nodata"); el.style.display = "flex"; } }, 12000); if (eloChart) { if (currentPlayerId && eloHistoryTierByPlayer[String(currentPlayerId)] === "full") { primeEloMarkerMatches(String(currentPlayerId)); } if (loading) loading.style.display = "none"; setTimeout(() => eloChart.resize(), CFG.ELO_RESIZE_DELAY_MS); } else if (eloData) { if (currentPlayerId && eloHistoryTierByPlayer[String(currentPlayerId)] === "full") { primeEloMarkerMatches(String(currentPlayerId)); } setTimeout(() => { document.getElementById("eloLoading").style.display = "none"; renderEloChart(eloData); }, CFG.ELO_RENDER_DELAY_MS); } else if (currentPlayerId) { loadEloHistory(currentPlayerId); } } } // ── CAUTARE ─────────────────────────────────────────────── function triggerSearch(q, deep = false) { if (!q || q.length === 0) return; searchResults.innerHTML = `
${randomSearchMsg()}
`; searchResults.classList.remove("hidden"); if (deep) doDeepSearch(q); else doSearch(q); } if (searchInput) { searchInput.addEventListener("focus", () => { if (window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX) { document.querySelector("header").classList.add("search-active"); } }); searchInput.addEventListener("input", () => { clearTimeout(searchTimer); const q = searchInput.value.trim(); if (q.length === 0) { closeSearch(); return; } // Arata mesajul funny imediat, inainte de debounce searchResults.innerHTML = `
${randomSearchMsg()}
`; searchResults.classList.remove("hidden"); searchTimer = setTimeout(() => triggerSearch(q, false), CFG.SEARCH_DEBOUNCE_MS); }); searchInput.addEventListener("keydown", e => { if (e.key === "Escape") closeSearch(); if (e.key === "Enter") { clearTimeout(searchTimer); triggerSearch(searchInput.value.trim(), true); } }); } const searchBtn = document.getElementById("searchBtn"); if (searchBtn) { searchBtn.addEventListener("click", () => { clearTimeout(searchTimer); triggerSearch(searchInput?.value.trim(), true); }); } document.addEventListener("click", e => { if (!e.target.closest(".search-wrap")) closeSearch(); }); const SEARCH_LOADING_MSGS = { en: [ "Searching among millions of stars...", "Asking the ping-pong gods...", "Checking the VR multiverse...", "Scanning all known paddles...", "Consulting the table tennis oracle...", "Spinning up the search engine...", "Rallying through the database...", ], de: [ "Suche unter Millionen von Sternen...", "Die Tischtennis-Götter werden befragt...", "Das VR-Multiversum wird durchsucht...", "Alle Schläger werden gescannt...", "Das Orakel wird konsultiert...", ], fr: [ "Recherche parmi des millions d'étoiles...", "Consultation des dieux du ping-pong...", "Scan du multivers VR en cours...", "Interrogation de l'oracle...", "Fouille de la base de données...", ] }; function randomSearchMsg() { const msgs = SEARCH_LOADING_MSGS[currentLang] || SEARCH_LOADING_MSGS.en; return msgs[Math.floor(Math.random() * msgs.length)]; } let searchMsgInterval = null; function startSearchMsgRotation() { stopSearchMsgRotation(); // Schimbam mesajul la fiecare 1.8s searchMsgInterval = setInterval(() => { const el = searchResults.querySelector(".search-loading-msg"); if (el) el.textContent = randomSearchMsg(); }, CFG.SEARCH_MSG_ROTATION_MS); } function stopSearchMsgRotation() { if (searchMsgInterval) { clearInterval(searchMsgInterval); searchMsgInterval = null; } } function closeSearch() { stopSearchMsgRotation(); searchResults.classList.add("hidden"); searchResults.innerHTML = ""; document.querySelector("header")?.classList.remove("search-active"); } function renderSearchApiWarning() { searchResults.innerHTML = `
ElevenVR API requests failed. Search cannot run right now.
`; searchResults.classList.remove("hidden"); } // Deep search: pagineaza automat pana gaseste rezultate care incep cu query-ul async function doDeepSearch(query) { const myToken = ++searchToken; const q = query.toLowerCase(); searchPool = {}; searchPoolQuery = query.toUpperCase(); // La prima pagina, fetch paralel cu ID direct daca e numeric const idMatchPromise = fetchIdMatch(query); try { for (let page = 1; ; page++) { if (myToken !== searchToken) return; // Actualizam mesajul o data la doua pagini if (page % 2 === 1) { const loadingEl = searchResults.querySelector(".search-loading-msg"); if (loadingEl) loadingEl.textContent = randomSearchMsg(); } const [data, idMatch] = await Promise.all([ apiViaProxy(`/accounts/search/${encodeURIComponent(query.toUpperCase())}?page[number]=${page}&page[size]=${CFG.DEEP_SEARCH_PAGE_SIZE}`, 20), page === 1 ? idMatchPromise : Promise.resolve(null), ]); if (myToken !== searchToken) return; const results = data.data || []; results.forEach(p => { searchPool[p.id] = p; }); if (idMatch) searchPool[idMatch.id] = idMatch; // Verificam daca am gasit macar un rezultat care incepe cu query-ul const foundStartsWith = results.some( p => (p.attributes?.["user-name"] || "").toLowerCase().startsWith(q) ); // Oprim daca am gasit sau nu mai sunt pagini if (foundStartsWith || results.length < CFG.DEEP_SEARCH_PAGE_SIZE) break; } if (myToken !== searchToken) return; renderSearchResults(Object.values(searchPool), query); } catch (err) { if (myToken !== searchToken) return; renderSearchApiWarning(); } } // Fetch direct dupa ID — returneaza playerul cu _idMatch:true sau null async function fetchIdMatch(query) { if (!/^\d+$/.test(query)) return null; try { const data = await api(`/accounts/${query}`); const p = data.data?.[0] || data.data; if (p && p.id) { p._idMatch = true; return p; } } catch (e) { /* id inexistent, ignoram */ } return null; } async function doSearch(query) { // Incrementam token-ul — orice request anterior devine invalid const myToken = ++searchToken; // Daca userul a sters caractere (query mai scurt), resetam pool-ul if (!query.toUpperCase().startsWith(searchPoolQuery)) { searchPool = {}; searchPoolQuery = ""; } try { // Fetch paralel: cautare normala + ID direct (daca e numeric) const [data, idMatch] = await Promise.all([ api(`/accounts/search/${encodeURIComponent(query.toUpperCase())}`), fetchIdMatch(query), ]); if (myToken !== searchToken) return; // Adaugam rezultatele in pool (dedup dupa id) (data.data || []).forEach(p => { searchPool[p.id] = p; }); if (idMatch) searchPool[idMatch.id] = idMatch; searchPoolQuery = query.toUpperCase(); renderSearchResults(Object.values(searchPool), query); } catch (err) { if (myToken !== searchToken) return; renderSearchApiWarning(); } } function renderSearchResults(players, query) { // Sortam rezultatele dupa relevanta (fara filtrare, sa nu pierdem rezultate): // 1. Potrivire exacta (ex: "GODA") // 2. Incepe cu query (ex: "GODANA") // 3. Contine query (ex: "XGODAX") if (query) { const q = query.toLowerCase(); players = [...players].sort((a, b) => { const nameA = (a.attributes?.["user-name"] || ""); const nameB = (b.attributes?.["user-name"] || ""); const na = nameA.toLowerCase(); const nb = nameB.toLowerCase(); const scoreA = a._idMatch ? -1 : na === q ? 0 : na.startsWith(q) ? 1 : 2; const scoreB = b._idMatch ? -1 : nb === q ? 0 : nb.startsWith(q) ? 1 : 2; if (scoreA !== scoreB) return scoreA - scoreB; // Daca doua nume difera doar prin litere mari/mici, prioritate pentru ELO mai mare. if (na === nb && nameA !== nameB) { const eloA = Number(a.attributes?.elo || 0); const eloB = Number(b.attributes?.elo || 0); if (eloA !== eloB) return eloB - eloA; } return na.localeCompare(nb); }); } if (players.length === 0) { searchResults.innerHTML = `
${t("search_none")}
`; searchResults.classList.remove("hidden"); return; } searchResults.innerHTML = players.map(p => { const a = p.attributes; const idLabel = p._idMatch ? ` Player ID: ${p.id}` : ""; return `
${escHtml(a["user-name"])}${idLabel} ${a.elo != null ? Math.round(a.elo) : "—"} ${countryFlag(a["country-code"])}
`; }).join(""); searchResults.classList.remove("hidden"); searchResults.querySelectorAll(".search-item[data-id]").forEach(el => { el.addEventListener("click", () => { const id = el.dataset.id; if (searchInput) searchInput.value = el.dataset.name; closeSearch(); loadPlayer(id); }); }); } // ── PROFIL JUCATOR ──────────────────────────────────────── async function loadPlayer(id) { const prevId = currentPlayerId ? String(currentPlayerId) : null; const nextId = String(id); if (!suppressProfileBackPush && prevId && prevId !== nextId) { profileBackStack.push(prevId); if (profileBackStack.length > 40) profileBackStack = profileBackStack.slice(-40); } landing.classList.add("hidden"); profileSection.classList.remove("hidden"); // La fiecare profil nou, pornim din top-ul paginii (desktop + mobile). window.scrollTo({ top: 0, left: 0, behavior: "auto" }); setUrlId(id); resetProfile(); currentPlayerId = id; // setam imediat ca guard-ul din loadHofQuick sa functioneze updateProfileBackButton(); try { const data = await api(`/accounts/${id}`); const p = data.data?.[0] || data.data; if (!p) throw new Error("Player not found."); currentPlayer = p; const lastOnline = p.attributes?.["last-online"] || null; document.getElementById("eloCollapse").classList.remove("open"); document.getElementById("eloIcon").textContent = t("elo_show"); renderProfile(p); setPageTitleForProfile(p?.attributes?.["user-name"]); refreshHeroFirstActive(id); // Inregistram vizita pentru Most Viewed const mvElo = p.attributes?.elo ?? null; const mvRank = p.attributes?.rank ?? null; const mvName = p.attributes?.["user-name"] || null; const mvCountry = p.attributes?.["country-code"] || null; mvRecord(id, mvName, mvCountry, mvElo, mvRank); if (typeof gtag === "function" && mvName) { gtag("event", "view_player", { player_id: id, player_name: mvName, player_rank: mvRank ?? undefined, }); } matchPage = 1; loadHofQuick(id, lastOnline); loadMatches(id, matchPage, lastOnline); } catch (err) { console.error(err); const fallback = await readFullStatsCacheForProfile(id); const cachedProfile = buildProfileFromFullStatsCache(id, fallback.matches); if (cachedProfile) { setApiDataWarning( true, "Warning: ElevenVR API requests failed. This profile is loaded from local cache, so data may be outdated." ); currentPlayer = cachedProfile; renderProfile(cachedProfile); setPageTitleForProfile(cachedProfile?.attributes?.["user-name"]); refreshHeroFirstActive(id); const mvElo = cachedProfile.attributes?.elo ?? null; const mvRank = cachedProfile.attributes?.rank ?? null; const mvName = cachedProfile.attributes?.["user-name"] || null; const mvCountry = cachedProfile.attributes?.["country-code"] || null; mvRecord(id, mvName, mvCountry, mvElo, mvRank); matchPage = 1; loadHofQuick(id, null); loadMatches(id, matchPage, null); return; } setApiDataWarning( true, "Warning: ElevenVR API requests failed. Player profile cannot be loaded!" ); const heroNameEl = document.getElementById("heroName"); if (heroNameEl) heroNameEl.textContent = "Profile unavailable"; } } // ── MOST VIEWED ─────────────────────────────────────────── function mvGet() { try { return JSON.parse(localStorage.getItem("ettmv") || "{}"); } catch(e) { return {}; } } function mvSave(data) { localStorage.setItem("ettmv", JSON.stringify(data)); } const MV_LAST_COMBINED_KEY = "ettmv:lastCombined"; let mvContextMenuEl = null; let matchOppContextMenuEl = null; let matchOppTooltipAnchorEl = null; let matchOppSuppressedTooltipEls = []; function mvRecord(id, name, country, elo, rank) { const data = mvGet(); if (!data[id]) data[id] = { id, name, country, elo, rank, views: 0 }; data[id].views++; data[id].lastViewedAt = Date.now(); // Actualizam datele la fiecare vizita if (name) data[id].name = name; if (country) data[id].country = country; if (elo) data[id].elo = elo; if (rank !== undefined && rank !== null) data[id].rank = rank; mvSave(data); } function mvRecordCombined(mainId, secondaryIds, name, country, elo, rank) { const main = String(mainId || "").trim(); const secondaries = Array.isArray(secondaryIds) ? secondaryIds.map(x => String(x || "").trim()).filter(Boolean) : []; if (!main || !secondaries.length) return; const sortedSecondaries = secondaries.slice().sort().slice(0, 1); const key = `combined:${main}|${sortedSecondaries.join(",")}`; const url = `multiple?main=${encodeURIComponent(main)}&secondary=${encodeURIComponent(sortedSecondaries.join(","))}`; const data = mvGet(); if (!data[key]) { data[key] = { id: key, type: "combined", mainId: main, secondaryIds: sortedSecondaries, url, name, country, elo, rank, views: 0 }; data[key].createdAt = Date.now(); } data[key].views++; if (!Number.isFinite(Number(data[key].createdAt || 0))) { data[key].createdAt = Date.now(); } data[key].lastViewedAt = Date.now(); data[key].type = "combined"; data[key].mainId = main; data[key].secondaryIds = sortedSecondaries; data[key].url = url; if (name) data[key].name = name; if (country) data[key].country = country; if (elo) data[key].elo = elo; if (rank !== undefined && rank !== null) data[key].rank = rank; mvSave(data); try { localStorage.setItem(MV_LAST_COMBINED_KEY, key); } catch (_) {} } function mvOpen(id) { const data = mvGet(); const item = data[String(id || "")]; if (!item) return; if (item.type === "combined" && item.url) { window.location.href = String(item.url); return; } if (item.id) loadPlayer(String(item.id)); } function ensureMvContextMenu() { if (mvContextMenuEl) return mvContextMenuEl; const el = document.createElement("div"); el.className = "mv-context-menu hidden"; el.innerHTML = ``; document.body.appendChild(el); const removeBtn = el.querySelector("#mvCtxRemoveBtn"); if (removeBtn) { removeBtn.addEventListener("click", () => { const id = String(el.dataset.id || ""); if (id) mvRemoveSingle(id); hideMvContextMenu(); }); } mvContextMenuEl = el; return el; } function hideMvContextMenu() { if (!mvContextMenuEl) return; mvContextMenuEl.classList.add("hidden"); mvContextMenuEl.dataset.id = ""; } function mvRemoveSingle(id) { const key = String(id || "").trim(); if (!key) return; const data = mvGet(); if (!data[key]) return; delete data[key]; mvSave(data); try { const lastCombined = String(localStorage.getItem(MV_LAST_COMBINED_KEY) || "").trim(); if (lastCombined && lastCombined === key) localStorage.removeItem(MV_LAST_COMBINED_KEY); } catch (_) {} mvRender(); } function mvOpenContextMenu(ev, id) { ev.preventDefault(); ev.stopPropagation(); const key = String(id || "").trim(); if (!key) return false; const menu = ensureMvContextMenu(); menu.dataset.id = key; menu.classList.remove("hidden"); const vw = window.innerWidth || document.documentElement.clientWidth || 0; const vh = window.innerHeight || document.documentElement.clientHeight || 0; const rect = menu.getBoundingClientRect(); const pad = 8; let left = Number(ev.clientX || 0); let top = Number(ev.clientY || 0); if (left + rect.width > vw - pad) left = Math.max(pad, vw - rect.width - pad); if (top + rect.height > vh - pad) top = Math.max(pad, vh - rect.height - pad); menu.style.left = `${Math.round(left)}px`; menu.style.top = `${Math.round(top)}px`; return false; } function ensureMatchOppContextMenu() { if (matchOppContextMenuEl) return matchOppContextMenuEl; const el = document.createElement("div"); el.className = "mv-context-menu hidden"; el.innerHTML = ` `; document.body.appendChild(el); const h2hBtn = el.querySelector("#matchOppCtxH2hBtn"); const newTabBtn = el.querySelector("#matchOppCtxNewTabBtn"); const copyIdBtn = el.querySelector("#matchOppCtxCopyIdBtn"); const elevenBtn = el.querySelector("#matchOppCtxElevenBtn"); const clubhouseBtn = el.querySelector("#matchOppCtxClubhouseBtn"); if (h2hBtn) { h2hBtn.addEventListener("click", () => { const oppId = String(el.dataset.oppId || ""); const oppName = String(el.dataset.oppName || ""); hideMatchOppContextMenu(); if (!oppId) return; openMatchupHistory(oppId, oppName); }); } if (newTabBtn) { newTabBtn.addEventListener("click", () => { const oppId = String(el.dataset.oppId || ""); hideMatchOppContextMenu(); if (!oppId) return; const langPrefix = currentLang && currentLang !== "en" ? `${currentLang}/` : ""; window.open(`#${langPrefix}${encodeURIComponent(oppId)}`, "_blank", "noopener"); }); } if (copyIdBtn) { copyIdBtn.addEventListener("click", async () => { const oppId = String(el.dataset.oppId || ""); hideMatchOppContextMenu(); if (!oppId) return; const ok = await copyTextWithFallback(`Player ID: ${oppId}`); if (ok) showCopyToast(t("tip_player_id_copied", oppId), true); else showCopyToast("Copy failed. Please try again.", false); }); } if (elevenBtn) { elevenBtn.addEventListener("click", () => { const oppId = String(el.dataset.oppId || ""); hideMatchOppContextMenu(); if (!oppId) return; window.open(`https://app.elevenvr.com/#/player/${encodeURIComponent(oppId)}`, "_blank", "noopener"); }); } if (clubhouseBtn) { clubhouseBtn.addEventListener("click", () => { const oppId = String(el.dataset.oppId || ""); hideMatchOppContextMenu(); if (!oppId) return; window.open(`https://11clubhouse.com/${encodeURIComponent(oppId)}/`, "_blank", "noopener"); }); } matchOppContextMenuEl = el; return el; } function hideMatchOppContextMenu() { if (!matchOppContextMenuEl) return; if (matchOppTooltipAnchorEl) { const backup = matchOppTooltipAnchorEl.getAttribute("data-tooltip-backup"); if (backup !== null) { matchOppTooltipAnchorEl.setAttribute("data-tooltip", backup); matchOppTooltipAnchorEl.removeAttribute("data-tooltip-backup"); } matchOppTooltipAnchorEl = null; } if (Array.isArray(matchOppSuppressedTooltipEls) && matchOppSuppressedTooltipEls.length) { matchOppSuppressedTooltipEls.forEach((entry) => { const el = entry?.el; const val = entry?.val; if (!el || typeof el.setAttribute !== "function") return; if (val === null || val === undefined) return; el.setAttribute("data-tooltip", val); el.removeAttribute("data-tooltip-backup"); }); matchOppSuppressedTooltipEls = []; } matchOppContextMenuEl.classList.add("hidden"); matchOppContextMenuEl.dataset.oppId = ""; matchOppContextMenuEl.dataset.oppName = ""; } function matchOppOpenContextMenu(ev, oppId, oppName = "") { ev.preventDefault(); ev.stopPropagation(); const id = String(oppId || "").trim(); if (!id) return false; if (matchOppContextMenuEl && !matchOppContextMenuEl.classList.contains("hidden")) { hideMatchOppContextMenu(); } // Avoid overlap: hide the hover tooltip while context menu is open. const clickedLink = ev.target?.closest(".opp-link[data-opp-id], .hof-link[data-opp-id]"); const anchorTipEl = clickedLink?.querySelector(".opp-name-text[data-tooltip]") || null; if (anchorTipEl) { const currentTip = anchorTipEl.getAttribute("data-tooltip"); if (currentTip !== null) { anchorTipEl.setAttribute("data-tooltip-backup", currentTip); anchorTipEl.removeAttribute("data-tooltip"); matchOppTooltipAnchorEl = anchorTipEl; } } matchOppSuppressedTooltipEls = []; const hoverTooltipNodes = [ clickedLink, clickedLink?.closest(".hof-card[data-tooltip]"), ].filter(Boolean); hoverTooltipNodes.forEach((node) => { const val = node.getAttribute?.("data-tooltip"); if (val === null || val === undefined) return; node.setAttribute("data-tooltip-backup", val); node.removeAttribute("data-tooltip"); matchOppSuppressedTooltipEls.push({ el: node, val }); }); const name = String(oppName || "").trim(); const menu = ensureMatchOppContextMenu(); menu.dataset.oppId = id; menu.dataset.oppName = name; const h2hBtn = menu.querySelector("#matchOppCtxH2hBtn"); const newTabBtn = menu.querySelector("#matchOppCtxNewTabBtn"); const copyIdBtn = menu.querySelector("#matchOppCtxCopyIdBtn"); const elevenBtn = menu.querySelector("#matchOppCtxElevenBtn"); const clubhouseBtn = menu.querySelector("#matchOppCtxClubhouseBtn"); if (h2hBtn) h2hBtn.textContent = t("tip_matchup_history"); if (newTabBtn) newTabBtn.textContent = t("tip_open_profile_new_tab"); if (copyIdBtn) copyIdBtn.textContent = t("tip_copy_player_id", id); if (elevenBtn) elevenBtn.textContent = t("tip_open_profile_elevenvr"); if (clubhouseBtn) clubhouseBtn.textContent = t("tip_open_profile_clubhouse"); menu.classList.remove("hidden"); const vw = window.innerWidth || document.documentElement.clientWidth || 0; const vh = window.innerHeight || document.documentElement.clientHeight || 0; const rect = menu.getBoundingClientRect(); const pad = 8; let left = Number(ev.clientX || 0); let top = Number(ev.clientY || 0); if (left + rect.width > vw - pad) left = Math.max(pad, vw - rect.width - pad); if (top + rect.height > vh - pad) top = Math.max(pad, vh - rect.height - pad); menu.style.left = `${Math.round(left)}px`; menu.style.top = `${Math.round(top)}px`; return false; } function handleOpponentContextMenuEvent(ev) { const link = ev.target?.closest(".opp-link[data-opp-id], .hof-link[data-opp-id]"); if (!link) return; const oppId = String(link.getAttribute("data-opp-id") || "").trim(); const oppName = String(link.getAttribute("data-opp-name") || "").trim(); if (!oppId) return; matchOppOpenContextMenu(ev, oppId, oppName); } function mvRender() { const data = mvGet(); const section = document.getElementById("mostViewedSection"); const grid = document.getElementById("mostViewedGrid"); if (!section || !grid) return; const allEntries = Object.values(data); let entries = allEntries .sort((a, b) => b.views - a.views) .slice(0, 6); // Force-show the last displayed combined profile as slot #6 fallback. let forcedCombined = null; try { const forcedKey = String(localStorage.getItem(MV_LAST_COMBINED_KEY) || "").trim(); if (forcedKey && data[forcedKey]?.type === "combined") forcedCombined = data[forcedKey]; } catch (_) {} if (!forcedCombined) { forcedCombined = allEntries .filter(p => p?.type === "combined") .sort((a, b) => Number(b?.lastViewedAt || b?.createdAt || 0) - Number(a?.lastViewedAt || a?.createdAt || 0))[0] || null; } if (forcedCombined) { const alreadyVisible = entries.some(e => String(e?.id || "") === String(forcedCombined.id || "")); if (!alreadyVisible) { if (entries.length < 6) entries = [...entries, forcedCombined]; else entries[entries.length - 1] = forcedCombined; } } if (entries.length === 0) { section.style.display = "none"; return; } section.style.display = "block"; grid.innerHTML = entries.map(p => { const flagCode = (p.country && p.country !== "none" && p.country !== "Earth" && p.country !== "null" && p.country !== "International") ? p.country.toLowerCase() : "un"; const flag = ``; const badge = p.views > 1 ? `
${p.views}
` : ""; const elo = p.elo ? `${Math.round(p.elo)} ELO` : ""; const rankValue = Number(p.rank); const rank = Number.isFinite(rankValue) && rankValue > 0 ? `#${rankValue}` : `${t("unranked")}`; const subtitle = p.type === "combined" ? `
combined profile
` : ""; const idJs = escHtml(JSON.stringify(String(p.id || ""))); const cardClass = p.type === "combined" ? "mv-card mv-card-combined" : "mv-card"; return `
${badge}
${flag}
${escHtml(p.name || p.id)}
${subtitle}
${elo}${rank}
`; }).join(""); } function clearMostViewed() { localStorage.removeItem("ettmv"); try { localStorage.removeItem(MV_LAST_COMBINED_KEY); } catch (_) {} document.getElementById("mostViewedSection").style.display = "none"; } document.addEventListener("click", (ev) => { if (!mvContextMenuEl || mvContextMenuEl.classList.contains("hidden")) return; if (mvContextMenuEl.contains(ev.target)) return; hideMvContextMenu(); }); document.addEventListener("contextmenu", handleOpponentContextMenuEvent); document.addEventListener("click", (ev) => { if (!matchOppContextMenuEl || matchOppContextMenuEl.classList.contains("hidden")) return; if (matchOppContextMenuEl.contains(ev.target)) return; hideMatchOppContextMenu(); }); window.addEventListener("scroll", hideMvContextMenu, { passive: true }); window.addEventListener("scroll", hideMatchOppContextMenu, { passive: true }); window.addEventListener("resize", hideMvContextMenu); window.addEventListener("resize", hideMatchOppContextMenu); document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") { hideMvContextMenu(); hideMatchOppContextMenu(); } }); function loadImg(src) { return new Promise(resolve => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = src; }); } function flagUrl(code) { return code ? `https://flagcdn.com/48x36/${code.toLowerCase()}.png` : null; } function buildHofShareData(card) { const hofType = card.dataset.hofType || ""; const s = currentHofStats; const scopeKey = currentHofScopeKey; const scope = scopeKey === "hof_scope_full" ? "ALL MATCHES" : "LAST 100"; // Player info const playerName = currentPlayer?.attributes?.["user-name"] || "—"; const playerCountry = currentPlayer?.attributes?.["country-code"] || null; const playerElo = parseInt(document.getElementById("statElo")?.textContent) || null; // Helper: format date range function fmtDateRange(a, b) { if (!a && !b) return ""; const fmt = d => d ? new Date(d).toLocaleDateString("en-GB", {day:"numeric", month:"short", year:"numeric"}) : ""; if (a && b && a !== b) return `${fmt(a)} – ${fmt(b)}`; return fmt(a || b); } // Helper: format duration ms function fmtDur(ms) { if (!ms) return ""; const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); return h > 0 ? `${h}h ${m}min` : `${m}min`; } function fmtDurMinSec(ms) { if (!ms) return ""; const totalSec = Math.max(0, Math.floor(ms / 1000)); const m = Math.floor(totalSec / 60); const s = totalSec % 60; return `${m}m ${String(s).padStart(2, "0")}s`; } function fmtClockFromMinutes(mins) { const n = Number(mins); if (!Number.isFinite(n)) return "—"; const m = ((Math.round(n) % 1440) + 1440) % 1440; const hh = String(Math.floor(m / 60)).padStart(2, "0"); const mm = String(m % 60).padStart(2, "0"); return `${hh}:${mm}`; } if (hofType === "upset_win" && s?.bestUpsetWin) { const m = s.bestUpsetWin; const sets = m.sets || []; const mySets = sets.filter(x => x.my > x.opp).length; const oppSets = sets.filter(x => x.opp > x.my).length; return { layout: "vs", type: "SIGNATURE VICTORY", icon: "⚡", playerName, playerElo: Number.isFinite(m.myElo) ? m.myElo : playerElo, playerCountry, oppName: m.oppName, oppElo: Math.round(m.oppElo || 0), oppCountry: m.oppCountry, playerSets: mySets, oppSets, sets: sets.map(x => ({my: x.my, opp: x.opp})), eloChange: m.eloChange > 0 ? `+${m.eloChange}` : null, matchType: m.isRanked ? "Ranked" : "Casual", date: m.date, scope, }; } if (hofType === "upset_loss" && s?.worstUpsetLoss) { const m = s.worstUpsetLoss; const sets = m.sets || []; const mySets = sets.filter(x => x.my > x.opp).length; const oppSets = sets.filter(x => x.opp > x.my).length; return { layout: "vs", type: "UNDERDOG STING", icon: "💀", playerName, playerElo: Number.isFinite(m.myElo) ? m.myElo : playerElo, playerCountry, oppName: m.oppName, oppElo: Math.round(m.oppElo || 0), oppCountry: m.oppCountry, playerSets: mySets, oppSets, sets: sets.map(x => ({my: x.my, opp: x.opp})), eloChange: m.eloChange ? `−${m.eloChange}` : null, matchType: m.isRanked ? "Ranked" : "Casual", date: m.date, scope, }; } if (hofType === "comeback" && s?.bestComeback) { const m = s.bestComeback; const sets = m.sets || []; const mySets = sets.filter(x => x.my > x.opp).length; const oppSets = sets.filter(x => x.opp > x.my).length; return { layout: "vs", type: "BIGGEST COMEBACK", icon: "🎯", playerName, playerElo: Number.isFinite(m.myElo) ? m.myElo : playerElo, playerCountry, oppName: m.oppName, oppElo: Math.round(m.oppElo || 0), oppCountry: m.oppCountry, playerSets: mySets, oppSets, sets: sets.map(x => ({my: x.my, opp: x.opp})), eloChange: m.eloChange ? `+${m.eloChange}` : null, matchType: "Ranked", date: m.date, scope, }; } if (hofType === "win_streak") { return { layout: "solo", type: "LONGEST WIN STREAK", icon: "🔥", playerName, playerElo: Number.isFinite(s.winStreakEndElo) ? s.winStreakEndElo : playerElo, playerCountry, heroNum: s.maxWinStreak, heroLabel: "RANKED WINS IN A ROW", heroColor: "#22c55e", dateRange: fmtDateRange(s.winStreakStart, s.winStreakEnd), scope, }; } if (hofType === "loss_streak") { return { layout: "solo", type: "LONGEST LOSS STREAK", icon: "❄️", playerName, playerElo: Number.isFinite(s.lossStreakEndElo) ? s.lossStreakEndElo : playerElo, playerCountry, heroNum: s.maxLossStreak, heroLabel: "RANKED LOSSES IN A ROW", heroColor: "#ef4444", dateRange: fmtDateRange(s.lossStreakStart, s.lossStreakEnd), scope, }; } if (hofType === "top_opp" && s?.topOpp) { const o = s.topOpp; const rankedCount = Number(o.ranked || 0); const unrankedCount = Math.max(0, Number(o.total || 0) - rankedCount); const topOppIcon = rankedCount > unrankedCount ? "🤜🤛" : "🤝"; return { layout: "vs_total", type: "MOST PLAYED OPPONENT", icon: topOppIcon, playerName, playerElo, playerCountry, oppName: o.name, oppElo: o.lastElo || null, oppCountry: o.country, total: o.total, ranked: o.ranked, unranked: o.total - o.ranked, totalMatches: s.totalMatches || 0, scope, }; } if (hofType === "longest_match" && s?.longestMatch) { const m = s.longestMatch; const sets = m.sets || []; const mySets = sets.filter(x => x.my > x.opp).length; const oppSets = sets.filter(x => x.opp > x.my).length; return { layout: "vs", type: "LONGEST MATCH", icon: "⏱️", playerName, playerElo: Number.isFinite(m.myElo) ? m.myElo : playerElo, playerCountry, oppName: m.oppName, oppElo: m.oppElo || null, oppCountry: m.oppCountry, playerSets: mySets, oppSets, sets: sets.map(x => ({my: x.my, opp: x.opp})), duration: fmtDurMinSec(m.durMs), pointsPlayed: Number.isFinite(Number(m.pointsPlayed)) ? Math.round(Number(m.pointsPlayed)) : null, eloChange: m.isRanked && Number(m.eloChange) !== 0 ? `${Number(m.won) ? "+" : "-"}${Math.abs(Math.round(Number(m.eloChange)))}` : null, matchType: m.isRanked ? "Ranked" : "Casual", date: m.date, scope, }; } if (hofType === "fast_furious" && s?.fastFurious) { const m = s.fastFurious; const sets = m.sets || []; return { layout: "vs", type: "FAST & FURIOUS", icon: "💥", playerName, playerElo: Number.isFinite(m.myElo) ? m.myElo : playerElo, playerCountry, oppName: m.oppName, oppElo: m.oppElo || null, oppCountry: m.oppCountry, playerSets: Number.isFinite(m.mySets) ? m.mySets : sets.filter(x => x.my > x.opp).length, oppSets: Number.isFinite(m.oppSets) ? m.oppSets : sets.filter(x => x.opp > x.my).length, sets: sets.map(x => ({ my: x.my, opp: x.opp })), duration: fmtDurMinSec(m.durMs), eloChange: m.isRanked && Number(m.eloChange) !== 0 ? `+${Math.abs(Math.round(Number(m.eloChange)))}` : null, matchType: m.isRanked ? "Ranked" : "Casual", date: m.date, scope, pointsPlayed: Number.isFinite(m.pointsPlayed) ? Math.round(m.pointsPlayed) : null }; } if (hofType === "prime_time" && s?.primeTime) { const p = s.primeTime; const start = fmtClockFromMinutes(p.avgStartMin); const end = fmtClockFromMinutes(p.avgEndMin); return { layout: "solo_prime", type: "PRIME TIME", icon: "🕒", playerName, playerElo, playerCountry, startTime: start, endTime: end, sessions: Number(p.sessions || 0), scope }; } if (hofType === "most_active_day" && s?.mostActiveDay) { const d = s.mostActiveDay; return { layout: "solo_2stat", type: "MOST ACTIVE DAY", icon: "📅", playerName, playerElo: Number.isFinite(d.playerElo) ? d.playerElo : playerElo, playerCountry, stat1Num: String(d.count), stat1Label: "MATCHES", stat1Color: "#fbbf24", stat2Num: fmtDur(d.durMs), stat2Label: "ACTUAL PLAYING TIME", stat2Color: "#e8e8ee", date: d.date, scope, }; } if (hofType === "best_day" && s?.bestDayElo) { const d = s.bestDayElo; return { layout: "solo_2stat", type: "BEST DAY EVER", icon: "📈", playerName, playerElo: Number.isFinite(d.playerElo) ? d.playerElo : playerElo, playerCountry, stat1Num: `+${d.net}`, stat1Label: "ELO GAINED", stat1Color: "#22c55e", stat2Num: String(d.wins), stat2Label: d.wins === 1 ? "RANKED VICTORY" : "RANKED VICTORIES", stat2Color: "#fbbf24", date: d.date, scope, }; } if (hofType === "nemesis" && s?.nemesis) { const o = s.nemesis; return { layout: "vs_h2h", type: "DIE HARD", icon: "🛡️", playerName, playerElo, playerCountry, oppName: o.name, oppElo: o.lastElo, oppCountry: o.country, wins: o.wins, losses: o.losses, scope, totalRanked: s.totalRanked || 0, }; } if (hofType === "fav_victim" && s?.favVictim) { const o = s.favVictim; return { layout: "vs_h2h", type: "FAVORITE VICTIM", icon: "🏅", playerName, playerElo, playerCountry, oppName: o.name, oppElo: o.lastElo, oppCountry: o.country, wins: o.wins, losses: o.losses, scope, totalRanked: s.totalRanked || 0, }; } if (hofType === "rivalry" && s?.topRivalry) { const o = s.topRivalry; return { layout: "vs_h2h", type: "LONGEST RIVALRY", icon: "⚔️", playerName, playerElo, playerCountry, oppName: o.name, oppElo: o.lastElo, oppCountry: o.country, wins: o.wins, losses: o.losses, scope, totalRanked: s.totalRanked || 0, }; } if (hofType === "forfeit_me") { const denom = abandonRankedDenominator(s, scopeKey); const pct = denom > 0 ? Math.min(100, Math.round((s.abandonMeImpact / denom) * 100)) : 0; return { layout: "solo_forfeit", type: "PLAYER FORFEITS", icon: "🏳️", playerName, playerElo, playerCountry, pct, count: s.abandonMeImpact, totalRanked: denom, descLines: ["Ranked matches where this player left the game", "after at least one point was played."], scope, }; } if (hofType === "forfeit_me_zero") { const denom = abandonRankedDenominator(s, scopeKey); const pct = denom > 0 ? Math.min(100, Math.round((s.abandonMeZero / denom) * 100)) : 0; return { layout: "solo_forfeit", type: "EARLY EXITS", icon: "🚪", playerName, playerElo, playerCountry, pct, count: s.abandonMeZero, totalRanked: denom, descLines: ["Ranked matches where this player left the game", "before any point was played (0-0)."], scope, }; } if (hofType === "forfeit_opp") { const denom = abandonRankedDenominator(s, scopeKey); const pct = denom > 0 ? Math.min(100, Math.round((s.abandonOppImpact / denom) * 100)) : 0; return { layout: "solo_forfeit", type: "OPPONENT FORFEITS", icon: "🎁", playerName, playerElo, playerCountry, pct, count: s.abandonOppImpact, totalRanked: denom, descLines: ["Ranked matches where the opponent left the game", "after at least one point was played."], scope, }; } return null; } function shareCardCodeForHofType(hofType) { const map = { upset_win: "SGV", upset_loss: "UDS", comeback: "BGC", win_streak: "WST", loss_streak: "LST", top_opp: "MPO", longest_match: "LMT", most_active_day: "MAD", best_day: "BDE", nemesis: "DHD", fav_victim: "FVT", rivalry: "LRV", forfeit_me: "PLF", forfeit_me_zero: "ERX", forfeit_opp: "OPF", fast_furious: "FNF", prime_time: "PRT", }; return map[hofType] || "HOF"; } async function buildShareImage(data) { await document.fonts.ready; const [flagPlayer, flagOpp] = await Promise.all([ data.playerCountry ? loadImg(flagUrl(data.playerCountry)) : Promise.resolve(null), data.oppCountry ? loadImg(flagUrl(data.oppCountry)) : Promise.resolve(null), ]); const canvas = document.createElement("canvas"); const EXPORT_SCALE = 4 / 3; // render larger for sharper social previews canvas.width = Math.round(CFG.SHARE_W * EXPORT_SCALE); canvas.height = Math.round(CFG.SHARE_H * EXPORT_SCALE); const ctx = canvas.getContext("2d"); ctx.scale(EXPORT_SCALE, EXPORT_SCALE); const W = CFG.SHARE_W, H = CFG.SHARE_H, PAD = CFG.SHARE_PAD; const contentTop = 86; const contentBottom = 566; // ── BACKGROUND ── ctx.fillStyle = "#141417"; ctx.fillRect(0, 0, W, H); // ── TOP GRADIENT LINE ── const tg = ctx.createLinearGradient(0, 0, W, 0); tg.addColorStop(0, "#f97316"); tg.addColorStop(1, "#fbbf24"); ctx.fillStyle = tg; ctx.fillRect(0, 0, W, 5); // ── HEADER ── const headerY = 46; ctx.textBaseline = "middle"; ctx.font = "900 44px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; ctx.textAlign = "left"; ctx.fillText("ETT", PAD, headerY); const ettW = ctx.measureText("ETT").width; ctx.fillStyle = "#e8e8ee"; ctx.fillText("STATS", PAD + ettW + 6, headerY); ctx.font = "700 40px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; ctx.textAlign = "right"; ctx.fillText(`${data.icon} ${data.type}`, W - PAD, headerY); // ── HELPERS ── function hline(y) { ctx.strokeStyle = "#2a2a32"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(PAD, y); ctx.lineTo(W - PAD, y); ctx.stroke(); } function vline(x, y1, y2) { ctx.strokeStyle = "#2a2a32"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke(); } function roundRect(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } hline(contentTop); const leftCX = W / 4, rightCX = (W * 3) / 4; const flagW = 64, flagH = 48; // ── LAYOUT: VS (Signature Victory, Underdog Sting, Biggest Comeback, Longest Match) ── if (data.layout === "vs") { const isFeaturedVsCard = data.type === "SIGNATURE VICTORY" || data.type === "UNDERDOG STING" || data.type === "BIGGEST COMEBACK" || data.type === "LONGEST MATCH" || data.type === "FAST & FURIOUS"; const isLongestMatchCard = data.type === "LONGEST MATCH"; const isFastFuriousCard = data.type === "FAST & FURIOUS"; const isLongestLikeCard = isLongestMatchCard || isFastFuriousCard; // Vertical separator if (!isFeaturedVsCard) { vline(W / 2, contentTop, contentBottom); } // Flags if (flagPlayer) ctx.drawImage(flagPlayer, leftCX - flagW / 2, 104, flagW, flagH); if (flagOpp) ctx.drawImage(flagOpp, rightCX - flagW / 2, 104, flagW, flagH); // Names ctx.textBaseline = "middle"; ctx.font = "900 52px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.fillText(data.playerName, leftCX, 182); ctx.fillText(data.oppName, rightCX, 182); if (isFeaturedVsCard || isLongestMatchCard) { ctx.font = "600 24px 'Barlow Condensed'"; ctx.fillStyle = "#7a7a8a"; ctx.fillText("vs", W / 2, 182); } // ELOs const eloY = isFeaturedVsCard ? 238 : 220; const eloFont = isFeaturedVsCard ? "700 42px 'Barlow Condensed'" : "700 32px 'Barlow Condensed'"; ctx.font = eloFont; ctx.fillStyle = "#f97316"; if (data.playerElo) ctx.fillText(`${data.playerElo} ELO`, leftCX, eloY); if (data.oppElo) ctx.fillText(`${data.oppElo} ELO`, rightCX, eloY); if (isFeaturedVsCard && data.type !== "BIGGEST COMEBACK" && data.type !== "LONGEST MATCH" && data.type !== "FAST & FURIOUS" && data.playerElo && data.oppElo) { const diff = data.oppElo - data.playerElo; const diffSign = diff > 0 ? "+" : ""; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = "900 54px 'Barlow Condensed'"; ctx.fillStyle = "#fbbf24"; ctx.fillText(`${diffSign}${diff}`, W / 2, 236); ctx.font = "700 22px 'Barlow Condensed'"; ctx.fillStyle = "#9a9aaa"; ctx.fillText("ELO DIFF", W / 2, 278); } if (isLongestLikeCard && Number.isFinite(Number(data.pointsPlayed))) { ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = "900 78px 'Barlow Condensed'"; ctx.fillStyle = "#fbbf24"; ctx.fillText(`${Math.round(Number(data.pointsPlayed))}`, W / 2, 236); ctx.font = "700 20px 'Barlow Condensed'"; ctx.fillStyle = "#9a9aaa"; ctx.fillText("POINTS PLAYED", W / 2, 274); } // Set scores (large) ctx.font = "900 115px 'Barlow Condensed'"; ctx.textBaseline = "middle"; ctx.fillStyle = "#22c55e"; ctx.fillText(data.playerSets, leftCX, 330); ctx.fillStyle = "#ef4444"; ctx.fillText(data.oppSets, rightCX, 330); if (isLongestLikeCard && data.duration) { ctx.font = "900 86px 'Barlow Condensed'"; ctx.fillStyle = "#fbbf24"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(data.duration, W / 2, 334); } hline(392); // Set boxes const sets = data.sets || []; if (sets.length > 0) { const isBiggestComeback = data.type === "BIGGEST COMEBACK"; const isSignatureOrUnderdog = data.type === "SIGNATURE VICTORY" || data.type === "UNDERDOG STING"; const compactSetBoxes = isLongestLikeCard || isSignatureOrUnderdog; const boxW = compactSetBoxes ? 74 : 84; const boxH = isBiggestComeback ? 92 : (compactSetBoxes ? 48 : 56); const boxR = 8, gap = 12; const totalBoxW = sets.length * boxW + (sets.length - 1) * gap; let bx = W / 2 - totalBoxW / 2; const by = isBiggestComeback ? 360 : 396; sets.forEach(set => { const myW = set.my > set.opp; ctx.fillStyle = "#1e1e24"; roundRect(bx, by, boxW, boxH, boxR); ctx.fill(); ctx.font = isBiggestComeback ? "700 52px 'Barlow Condensed'" : (compactSetBoxes ? "700 40px 'Barlow Condensed'" : "700 30px 'Barlow Condensed'"); ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.fillStyle = myW ? "#22c55e" : "#ef4444"; ctx.fillText(`${set.my}–${set.opp}`, bx + boxW / 2, by + boxH / 2); bx += boxW + gap; }); } // ELO change + match type (right side, below sets) const infoY = 472; if (isFeaturedVsCard) { const isRanked = (data.matchType || "").toLowerCase() === "ranked"; const leftText = `${isRanked ? "RANKED" : "CASUAL"} MATCH`; const rightText = isRanked && data.eloChange ? ` · ${data.eloChange} ELO gained` : ""; ctx.textBaseline = "middle"; ctx.textAlign = "left"; ctx.font = "700 34px 'Barlow Condensed'"; const leftW = ctx.measureText(leftText).width; const rightW = rightText ? ctx.measureText(rightText).width : 0; const startX = (W - (leftW + rightW)) / 2; ctx.fillStyle = "#c8c8d8"; ctx.fillText(leftText, startX, infoY); if (rightText) { ctx.fillStyle = "#8a8a9a"; ctx.fillText(rightText, startX + leftW, infoY); } } else if (data.eloChange) { ctx.font = "700 36px 'Barlow Condensed'"; ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.fillStyle = data.eloChange.startsWith("+") ? "#22c55e" : "#ef4444"; ctx.fillText(data.eloChange + " ELO", W / 2, infoY); } if (data.duration && !isLongestLikeCard) { ctx.font = "700 36px 'Barlow Condensed'"; ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.fillStyle = "#fbbf24"; ctx.fillText(data.duration, W / 2, infoY); } hline(500); // Date + match type const dateStr = data.date ? new Date(data.date).toLocaleDateString("en-GB", {day:"numeric", month:"long", year:"numeric"}) : ""; const dateY = (500 + contentBottom) / 2; ctx.font = "700 40px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(dateStr, W / 2, dateY); if (data.matchType && !isFeaturedVsCard) { ctx.font = "400 20px 'Barlow Condensed'"; ctx.fillStyle = "#6a6a7a"; ctx.fillText(data.matchType.toLowerCase() + " match", W / 2, 548); } } // ── LAYOUT: VS_H2H (Die Hard, Favorite Victim, Longest Rivalry) ── else if (data.layout === "vs_h2h") { const isDieHardCard = data.type === "DIE HARD"; const isFavoriteVictimCard = data.type === "FAVORITE VICTIM"; const isLongestRivalryCard = data.type === "LONGEST RIVALRY"; const isEnhancedH2HCard = isDieHardCard || isFavoriteVictimCard || isLongestRivalryCard; if (!isEnhancedH2HCard) { vline(W / 2, contentTop, 248); } if (flagPlayer) ctx.drawImage(flagPlayer, leftCX - flagW / 2, 104, flagW, flagH); if (flagOpp) ctx.drawImage(flagOpp, rightCX - flagW / 2, 104, flagW, flagH); ctx.textBaseline = "middle"; ctx.font = isEnhancedH2HCard ? "900 68px 'Barlow Condensed'" : "900 52px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.fillText(data.playerName, leftCX, 182); ctx.fillText(data.oppName, rightCX, 182); ctx.font = isEnhancedH2HCard ? "700 46px 'Barlow Condensed'" : "700 32px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; if (data.playerElo) ctx.fillText(`${data.playerElo} ELO`, leftCX, isEnhancedH2HCard ? 236 : 220); if (data.oppElo) ctx.fillText(`${data.oppElo} ELO`, rightCX, isEnhancedH2HCard ? 236 : 220); // H2H score const playerColor = data.wins >= data.losses ? "#22c55e" : "#ef4444"; const oppColor = data.wins >= data.losses ? "#ef4444" : "#22c55e"; ctx.font = isLongestRivalryCard ? "900 70px 'Barlow Condensed'" : "900 52px 'Barlow Condensed'"; ctx.fillStyle = playerColor; ctx.textAlign = "right"; ctx.fillText(data.wins, W / 2 - 28, 182); ctx.fillStyle = oppColor; ctx.textAlign = "left"; ctx.fillText(data.losses, W / 2 + 28, 182); ctx.font = "600 18px 'Barlow Condensed'"; ctx.fillStyle = "#4a4a5a"; ctx.textAlign = "center"; ctx.fillText("vs", W / 2, 182); const topSepY = isLongestRivalryCard ? 268 : (isEnhancedH2HCard ? 264 : 248); hline(topSepY); const total = data.wins + data.losses; if (data.type === "LONGEST RIVALRY") { ctx.font = isLongestRivalryCard ? "900 142px 'Barlow Condensed'" : "900 120px 'Barlow Condensed'"; ctx.fillStyle = "#fbbf24"; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillText(total, W / 2, isLongestRivalryCard ? 382 : 372); ctx.font = isLongestRivalryCard ? "700 34px 'Barlow Condensed'" : "700 26px 'Barlow Condensed'"; ctx.fillStyle = "#ffffff"; ctx.textBaseline = "middle"; ctx.fillText("MATCHES PLAYED", W / 2, isLongestRivalryCard ? 414 : 400); ctx.font = isLongestRivalryCard ? "700 32px 'Barlow Condensed'" : "600 24px 'Barlow Condensed'"; ctx.fillStyle = "#c8c8d8"; ctx.fillText(`${data.wins} wins · ${data.losses} losses`, W / 2, isLongestRivalryCard ? 448 : 430); } else { const heroPct = data.wins >= data.losses ? Math.round(data.wins / total * 100) : Math.round(data.losses / total * 100); const heroColor = data.wins >= data.losses ? "#22c55e" : "#ef4444"; const heroLabel = data.wins >= data.losses ? "WIN RATE" : "LOSS RATE"; ctx.font = isEnhancedH2HCard ? "900 136px 'Barlow Condensed'" : "900 120px 'Barlow Condensed'"; ctx.fillStyle = heroColor; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillText(`${heroPct}%`, W / 2, isEnhancedH2HCard ? 378 : 372); ctx.font = isEnhancedH2HCard ? "700 40px 'Barlow Condensed'" : "700 26px 'Barlow Condensed'"; ctx.fillStyle = "#ffffff"; ctx.textBaseline = "middle"; ctx.fillText(heroLabel, W / 2, isEnhancedH2HCard ? 406 : 400); ctx.font = isEnhancedH2HCard ? "700 38px 'Barlow Condensed'" : "600 24px 'Barlow Condensed'"; ctx.fillStyle = "#c8c8d8"; ctx.fillText(`${total} matches · ${data.wins} wins · ${data.losses} losses`, W / 2, isEnhancedH2HCard ? 442 : 430); } const bottomSepY = isLongestRivalryCard ? 472 : (isEnhancedH2HCard ? 482 : 458); hline(bottomSepY); const isLast100Scope = String(data.scope || "").toUpperCase() === "LAST 100"; const scopeText = isEnhancedH2HCard ? (isLast100Scope ? "Selected from last 100 ranked matches" : `Selected from all ${data.totalRanked || 0} ranked matches`) : (isLast100Scope ? "Selected from last 100 ranked matches" : "Selected from all ranked matches"); ctx.font = "400 26px 'Barlow Condensed'"; ctx.fillStyle = "#9a9aaa"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(scopeText, W / 2, isEnhancedH2HCard ? 524 : 494); } // ── LAYOUT: VS_TOTAL (Most Played Opponent) ── else if (data.layout === "vs_total") { // Keep top area clean, similar to featured VS cards if (flagPlayer) ctx.drawImage(flagPlayer, leftCX - flagW / 2, 104, flagW, flagH); if (flagOpp) ctx.drawImage(flagOpp, rightCX - flagW / 2, 104, flagW, flagH); ctx.textBaseline = "middle"; ctx.font = "900 52px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.fillText(data.playerName, leftCX, 182); ctx.fillText(data.oppName, rightCX, 182); ctx.font = "700 42px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; if (data.playerElo) ctx.fillText(`${data.playerElo} ELO`, leftCX, 238); if (data.oppElo) ctx.fillText(`${data.oppElo} ELO`, rightCX, 238); ctx.font = "600 24px 'Barlow Condensed'"; ctx.fillStyle = "#7a7a8a"; ctx.fillText("vs", W / 2, 182); hline(264); ctx.font = "900 120px 'Barlow Condensed'"; ctx.fillStyle = "#fbbf24"; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillText(data.total, W / 2, 372); ctx.font = "700 26px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textBaseline = "middle"; ctx.fillText("MATCHES PLAYED", W / 2, 400); ctx.font = "600 24px 'Barlow Condensed'"; ctx.fillStyle = "#9a9aaa"; const sub = data.unranked > 0 ? `${data.ranked} ranked · ${data.unranked} casual` : `${data.ranked} ranked matches`; ctx.fillText(sub, W / 2, 430); hline(472); const totalAllMatches = Number(data.totalMatches || 0); const isLast100Scope = String(data.scope || "").toUpperCase() === "LAST 100"; const scopeText = isLast100Scope ? "Selected from last 100 matches" : `Selected from all ${totalAllMatches} matches`; ctx.font = "400 26px 'Barlow Condensed'"; ctx.fillStyle = "#9a9aaa"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(scopeText, W / 2, 518); ctx.font = "400 18px 'Barlow Condensed'"; ctx.fillStyle = "#6a6a7a"; ctx.fillText("ranked + casual", W / 2, 544); } // ── LAYOUT: SOLO (Win Streak, Loss Streak) ── else if (data.layout === "solo") { vline(W / 2, contentTop, contentBottom); const midY = (contentTop + contentBottom) / 2; const isLongestWinStreak = data.type === "LONGEST WIN STREAK"; const isLongestLossStreak = data.type === "LONGEST LOSS STREAK"; const isLongestStreakCard = isLongestWinStreak || isLongestLossStreak; // Left: player const leftFlagY = isLongestStreakCard ? midY - 88 : midY - 100; if (flagPlayer) ctx.drawImage(flagPlayer, leftCX - flagW / 2, leftFlagY, flagW, flagH); ctx.textBaseline = "middle"; ctx.font = "900 56px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.fillText(data.playerName, leftCX, isLongestStreakCard ? midY - 2 : midY - 18); ctx.font = "700 34px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; if (data.playerElo) ctx.fillText(`${data.playerElo} ELO`, leftCX, isLongestStreakCard ? midY + 44 : midY + 38); // Right: hero number ctx.font = "900 140px 'Barlow Condensed'"; ctx.fillStyle = data.heroColor; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillText(data.heroNum, rightCX, isLongestStreakCard ? midY + 24 : midY + 46); ctx.font = "700 28px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textBaseline = "middle"; ctx.fillText(data.heroLabel, rightCX, isLongestStreakCard ? midY + 50 : midY + 74); if (data.dateRange) { if (isLongestStreakCard) { ctx.font = "600 28px 'Barlow Condensed'"; ctx.fillStyle = "#c8c8d8"; ctx.fillText(data.dateRange, rightCX, midY + 82); ctx.font = "400 18px 'Barlow Condensed'"; ctx.fillStyle = "#7a7a8a"; ctx.fillText("Casual matches do not count!", rightCX, midY + 110); } else { ctx.font = "400 22px 'Barlow Condensed'"; ctx.fillStyle = "#6a6a7a"; ctx.fillText(data.dateRange, rightCX, midY + 106); } } } // ── LAYOUT: SOLO_2STAT (Most Active Day, Best Day Ever) ── else if (data.layout === "solo_2stat") { vline(W / 2, contentTop, contentBottom); const midY = (contentTop + contentBottom) / 2; const isMostActiveDayCard = data.type === "MOST ACTIVE DAY"; const isBestDayEverCard = data.type === "BEST DAY EVER"; // Left: player if (flagPlayer) ctx.drawImage(flagPlayer, leftCX - flagW / 2, midY - 100, flagW, flagH); ctx.textBaseline = "middle"; ctx.font = "900 56px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.fillText(data.playerName, leftCX, midY - 18); ctx.font = "700 34px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; if (data.playerElo) ctx.fillText(`${data.playerElo} ELO`, leftCX, midY + 38); // Right: 2 stats stacked // Stat 1 (large) ctx.font = "900 110px 'Barlow Condensed'"; ctx.fillStyle = data.stat1Color; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillText(data.stat1Num, rightCX, midY - 24); ctx.font = (isMostActiveDayCard || isBestDayEverCard) ? "700 34px 'Barlow Condensed'" : "700 24px 'Barlow Condensed'"; ctx.fillStyle = (isMostActiveDayCard || isBestDayEverCard) ? "#c8c8d8" : "#9a9aaa"; ctx.textBaseline = "middle"; ctx.fillText(data.stat1Label, rightCX, (isMostActiveDayCard || isBestDayEverCard) ? midY + 8 : midY - 2); // Stat 2 ctx.font = "900 80px 'Barlow Condensed'"; ctx.fillStyle = data.stat2Color; ctx.textBaseline = "alphabetic"; ctx.fillText(data.stat2Num, rightCX, midY + 96); ctx.font = (isMostActiveDayCard || isBestDayEverCard) ? "700 28px 'Barlow Condensed'" : "700 22px 'Barlow Condensed'"; ctx.fillStyle = "#9a9aaa"; ctx.textBaseline = "middle"; ctx.fillText(data.stat2Label, rightCX, (isMostActiveDayCard || isBestDayEverCard) ? midY + 124 : midY + 114); // Date if (data.date) { const dateStr = new Date(data.date).toLocaleDateString("en-GB", {day:"numeric", month:"long", year:"numeric"}); ctx.font = (isMostActiveDayCard || isBestDayEverCard) ? "700 48px 'Barlow Condensed'" : "600 26px 'Barlow Condensed'"; ctx.fillStyle = "#c8c8d8"; ctx.fillText(dateStr, leftCX, (isMostActiveDayCard || isBestDayEverCard) ? midY + 122 : midY + 110); } } // ── LAYOUT: SOLO_FORFEIT (Player Forfeits, Early Exits, Opponent Forfeits) ── else if (data.layout === "solo_forfeit") { vline(W / 2, contentTop, contentBottom); const midY = (contentTop + contentBottom) / 2; // Left: player if (flagPlayer) ctx.drawImage(flagPlayer, leftCX - flagW / 2, midY - 100, flagW, flagH); ctx.textBaseline = "middle"; ctx.font = "900 64px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.fillText(data.playerName, leftCX, midY - 14); ctx.font = "700 40px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; if (data.playerElo) ctx.fillText(`${data.playerElo} ELO`, leftCX, midY + 48); // Right: centered block ctx.font = "900 150px 'Barlow Condensed'"; ctx.fillStyle = "#fbbf24"; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillText(`${data.pct}%`, rightCX, midY - 10); ctx.font = "700 34px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textBaseline = "middle"; ctx.fillText(data.type, rightCX, midY + 16); ctx.font = "400 28px 'Barlow Condensed'"; ctx.fillStyle = "#6a6a7a"; (data.descLines || []).forEach((line, i) => { ctx.fillText(line, rightCX, midY + 54 + i * 34); }); ctx.font = "700 38px 'Barlow Condensed'"; ctx.fillStyle = "#c8c8d8"; ctx.fillText(`${data.count} of ${data.totalRanked} ranked matches`, rightCX, midY + 54 + (data.descLines.length) * 34 + 20); } // ── LAYOUT: SOLO_PRIME (Prime Time) ── else if (data.layout === "solo_prime") { vline(W / 2, contentTop, contentBottom); const midY = (contentTop + contentBottom) / 2; // Left: player if (flagPlayer) ctx.drawImage(flagPlayer, leftCX - flagW / 2, midY - 100, flagW, flagH); ctx.textBaseline = "middle"; ctx.font = "900 56px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textAlign = "center"; ctx.fillText(data.playerName, leftCX, midY - 18); ctx.font = "700 34px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; if (data.playerElo) ctx.fillText(`${data.playerElo} ELO`, leftCX, midY + 38); // Right: prime-time range + sessions ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.font = "900 92px 'Barlow Condensed'"; ctx.fillStyle = "#fbbf24"; ctx.fillText(`${data.startTime}-${data.endTime}`, rightCX, midY - 8); ctx.font = "700 30px 'Barlow Condensed'"; ctx.fillStyle = "#e8e8ee"; ctx.textBaseline = "middle"; ctx.fillText("AVERAGE SESSION WINDOW", rightCX, midY + 24); ctx.font = "600 28px 'Barlow Condensed'"; ctx.fillStyle = "#9a9aaa"; const sess = Number(data.sessions || 0); ctx.fillText(`Based on ${sess} session${sess === 1 ? "" : "s"}`, rightCX, midY + 64); } // ── FOOTER ── const footerY = contentBottom; hline(footerY); ctx.fillStyle = "#0d0d0f"; ctx.fillRect(0, footerY + 1, W, H - footerY - 1); const ftY = footerY + (H - footerY) / 2; ctx.textBaseline = "middle"; ctx.font = "700 40px 'Barlow Condensed'"; ctx.fillStyle = "#f97316"; ctx.textAlign = "left"; ctx.fillText("ettstats.eu", PAD, ftY); ctx.font = "400 34px 'Barlow Condensed'"; ctx.fillStyle = "#c8c8d8"; ctx.textAlign = "center"; ctx.fillText("Eleven VR Stats Tracker", W / 2, ftY); ctx.font = "400 34px 'Barlow Condensed'"; ctx.fillStyle = "#6a6a7a"; ctx.textAlign = "right"; ctx.fillText("Fan-made", W - PAD, ftY); return canvas; } async function shareHofCard(btn) { const card = btn.closest(".hof-card"); if (!card) return; const data = buildHofShareData(card); if (!data) return; const playerId = String(currentPlayer?.id || ""); const cardCode = shareCardCodeForHofType(card.dataset.hofType || ""); const localFileName = buildShareFileName(playerId, cardCode); btn.style.visibility = "hidden"; let dataUrl = null; try { const canvas = await buildShareImage(data); dataUrl = canvas.toDataURL("image/png"); const resp = await fetch("share.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: dataUrl, playerId, cardCode }), }); let result = {}; try { result = await resp.json(); } catch(_) {} if (!resp.ok || result.error) { const err = new Error(result.error || `HTTP_${resp.status}`); err.status = resp.status; throw err; } openShareCardModal(dataUrl, result.url, null, localFileName, { playerName: data.playerName || currentPlayer?.attributes?.["user-name"] || "", cardType: data.type || "", }); } catch(e) { if (dataUrl) openShareCardModal(dataUrl, null, resolveShareUploadErrorKey(e), localFileName); } finally { btn.style.visibility = ""; } } // hostedUrl = URL-ul imaginii pe server (ettstats.eu/shares/xxx.png) // imageUrl = base64 local pentru download let _shareCardHostedUrl = null; let _shareCardStatusTimer = null; function buildShareFileName(playerId, cardCode, dateObj = new Date()) { const pid = String(playerId || "").replace(/\D+/g, ""); const ccode = String(cardCode || "HOF").toUpperCase().replace(/[^A-Z]/g, "").slice(0, 3).padEnd(3, "X"); const yy = String(dateObj.getFullYear()).slice(-2); const m = String(dateObj.getMonth() + 1).padStart(2, "0"); const d = String(dateObj.getDate()).padStart(2, "0"); const mm = String(dateObj.getMinutes()).padStart(2, "0"); const ss = String(dateObj.getSeconds()).padStart(2, "0"); return `${pid || "0"}_${ccode}_${yy}${m}${d}${mm}${ss}.png`; } function fileNameFromUrl(url) { if (!url) return null; try { const u = new URL(url, window.location.origin); const raw = u.pathname.split("/").pop() || ""; const name = decodeURIComponent(raw); return name.endsWith(".png") ? name : null; } catch(_) { return null; } } function buildSharePreviewUrl(hostedImageUrl, playerName = "", cardType = "") { const fileName = fileNameFromUrl(hostedImageUrl); if (!fileName) return hostedImageUrl || null; const params = new URLSearchParams(); if (playerName) params.set("pn", playerName); const base = `${window.location.origin}/s/${encodeURIComponent(fileName)}`; const qs = params.toString(); return qs ? `${base}?${qs}` : base; } function resolveShareUploadErrorKey(err) { const msg = String(err?.message || "").toLowerCase(); const status = Number(err?.status || 0); if (status === 429 || msg.includes("too many requests")) return "share_card_err_rate"; if (status === 413 || msg.includes("too large")) { if (msg.includes("dimension")) return "share_card_err_dimensions"; return "share_card_err_size"; } if (status >= 500 || msg.includes("failed to save")) return "share_card_err_upload"; return "share_card_err_generic"; } function openShareCardModal(imageUrl, hostedUrl, errorKey = null, localFileName = null, shareMeta = null) { const modal = document.getElementById("shareCardModal"); const img = document.getElementById("shareCardPreview"); const linkBtn = document.getElementById("shareCardLinkBtn"); const urlBox = document.getElementById("shareCardUrlBox"); const statusEl = document.getElementById("shareCardStatus"); if (!modal || !img) return; const profileShareUrl = shareMeta?.profileShareUrl ? String(shareMeta.profileShareUrl) : ""; let sharePreviewUrl = null; if (profileShareUrl) { _shareCardHostedUrl = profileShareUrl; } else { sharePreviewUrl = hostedUrl ? buildSharePreviewUrl( hostedUrl, shareMeta?.playerName || currentPlayer?.attributes?.["user-name"] || "", shareMeta?.cardType || "" ) : null; _shareCardHostedUrl = sharePreviewUrl || hostedUrl || null; } if (imageUrl) { img.src = imageUrl; img.classList.remove("hidden"); } else { img.removeAttribute("src"); img.classList.add("hidden"); } modal.classList.remove("hidden"); if (_shareCardStatusTimer) { clearTimeout(_shareCardStatusTimer); _shareCardStatusTimer = null; } // Afisam / ascundem casuta cu URL hosted if (urlBox) { if (hostedUrl) { urlBox.value = _shareCardHostedUrl; urlBox.closest(".share-card-url-wrap").style.display = "none"; } else { urlBox.closest(".share-card-url-wrap").style.display = "none"; } } if (statusEl) { statusEl.classList.remove("hidden", "status-ok", "status-error", "status-center"); if (hostedUrl) { statusEl.classList.add("status-ok", "status-center"); statusEl.textContent = t("share_card_status_uploaded"); _shareCardStatusTimer = setTimeout(() => { const modalOpen = !modal.classList.contains("hidden"); if (!modalOpen) return; statusEl.classList.add("hidden"); statusEl.classList.remove("status-ok", "status-center"); if (urlBox) { const wrap = urlBox.closest(".share-card-url-wrap"); if (wrap) wrap.style.display = "flex"; } _shareCardStatusTimer = null; }, CFG.SHARE_STATUS_AUTOHIDE_MS); } else { statusEl.classList.add("status-error"); statusEl.textContent = t(errorKey || "share_card_status_local_only"); } } // Butonul de copy link: daca avem URL hosted il copiem pe acela, altfel profilul if (linkBtn) { const span = linkBtn.querySelector("span"); if (span) span.setAttribute("data-i18n", hostedUrl ? "share_card_copy_link" : "share_card_copy_profile"); if (span) span.textContent = t(hostedUrl ? "share_card_copy_link" : "share_card_copy_profile"); } // Download const dlBtn = document.getElementById("shareCardDownloadBtn"); if (dlBtn) dlBtn.onclick = () => { const hostedFileName = fileNameFromUrl(hostedUrl); const fallbackName = localFileName || buildShareFileName(currentPlayer?.id, "HOF"); const fileName = hostedFileName || fallbackName; const a = document.createElement("a"); a.download = fileName; a.href = imageUrl; a.click(); }; } function closeShareCardModal(e) { if (e && e.target !== document.getElementById("shareCardModal")) return; document.getElementById("shareCardModal")?.classList.add("hidden"); if (_shareCardStatusTimer) { clearTimeout(_shareCardStatusTimer); _shareCardStatusTimer = null; } const statusEl = document.getElementById("shareCardStatus"); if (statusEl) { statusEl.classList.add("hidden"); statusEl.classList.remove("status-ok", "status-error", "status-center"); statusEl.textContent = ""; } } /** True for iPhone/iPad/iPod and iPadOS (often reports as Mac + touch). */ function isLikelyIOSOrIPadTouch() { const ua = navigator.userAgent || ""; if (/iPad|iPhone|iPod/i.test(ua)) return true; if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return true; return false; } /** * Synchronous copy via document.execCommand — must run in the same user-gesture * turn as the tap. iPad/Safari often reject navigator.clipboard after await/fetch. */ function copyTextSyncExecCommand(text) { const value = String(text || ""); if (!value) return false; let ta = null; try { ta = document.createElement("textarea"); ta.value = value; ta.setAttribute("readonly", "readonly"); ta.style.cssText = "position:fixed;top:0;left:0;width:2px;height:2px;padding:0;margin:0;border:0;outline:0;opacity:0;box-sizing:border-box;font-size:16px;"; document.body.appendChild(ta); ta.focus({ preventScroll: true }); ta.select(); if (isLikelyIOSOrIPadTouch()) { ta.setSelectionRange(0, value.length); } return !!document.execCommand("copy"); } catch (_) { return false; } finally { if (ta && ta.parentNode) ta.parentNode.removeChild(ta); } } async function copyTextWithFallback(text) { const value = String(text || ""); if (!value) return false; if (isLikelyIOSOrIPadTouch() && copyTextSyncExecCommand(value)) return true; try { if (navigator?.clipboard?.writeText) { await navigator.clipboard.writeText(value); return true; } } catch (_) {} return copyTextSyncExecCommand(value); } let _copyToastTimer = null; function showCopyToast(message, ok = true) { let el = document.getElementById("copyToast"); if (!el) { el = document.createElement("div"); el.id = "copyToast"; el.className = "copy-toast"; document.body.appendChild(el); } el.textContent = message; el.classList.toggle("error", !ok); el.classList.add("show"); if (_copyToastTimer) clearTimeout(_copyToastTimer); _copyToastTimer = setTimeout(() => { el.classList.remove("show"); _copyToastTimer = null; }, 1800); } async function copyMatchId(matchId, triggerEl) { const id = String(matchId || "").trim(); if (!id) return; const payload = `Match ID: #${id}`; const ok = await copyTextWithFallback(payload); if (ok) { if (triggerEl && triggerEl.classList) { triggerEl.classList.add("copied"); setTimeout(() => triggerEl.classList.remove("copied"), 1200); } showCopyToast(`Match ID copied: #${id}`, true); } else { showCopyToast("Copy failed. Please try again.", false); } } async function shareCardCopyLink() { const urlToCopy = _shareCardHostedUrl || profileShareUrl(); const copied = await copyTextWithFallback(urlToCopy); if (copied) { const btn = document.getElementById("shareCardLinkBtn"); if (!btn) return; const span = btn.querySelector("span"); const orig = span.textContent; span.textContent = t("tip_share_copied"); btn.classList.add("copied"); setTimeout(() => { span.textContent = orig; btn.classList.remove("copied"); }, CFG.SHARE_COPY_FEEDBACK_MS); showCopyToast(t("tip_share_copied"), true); } else { showCopyToast("Copy failed. Please try again.", false); } } function profileShareUrl() { const isMultiplePage = /\/multiple(?:\.html)?\/?$/i.test(window.location.pathname || ""); if (isMultiplePage) { const url = new URL(window.location.href); const main = String(url.searchParams.get("main") || "").trim(); const secondary = String(url.searchParams.get("secondary") || "").trim(); url.pathname = url.pathname.replace(/\/multiple(?:\.html)?\/?$/i, "/m"); if (main) url.searchParams.set("main", main); if (secondary) url.searchParams.set("secondary", secondary); url.hash = ""; return url.toString(); } if (!currentPlayerId) return window.location.href; const name = currentPlayer?.attributes?.["user-name"] || `player-${currentPlayerId}`; const slug = String(name) .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || `player-${currentPlayerId}`; return `${window.location.origin}/p/${encodeURIComponent(currentPlayerId)}/${encodeURIComponent(slug)}`; } async function buildProfileShareCanvas(payload) { await document.fonts.ready; const W = 600; const H = 315; const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); // Background inspired by existing card shares. ctx.fillStyle = "#141417"; ctx.fillRect(0, 0, W, H); const tg = ctx.createLinearGradient(0, 0, W, 0); tg.addColorStop(0, "#f97316"); tg.addColorStop(1, "#fbbf24"); ctx.fillStyle = tg; ctx.fillRect(0, 0, W, 5); ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.strokeRect(18, 18, W - 36, H - 36); const flag = payload.countryCode ? await loadImg(flagUrl(payload.countryCode)) : null; // Row 1: flag + country (uppercase) const country = String(payload.countryName || "INTERNATIONAL").toUpperCase(); ctx.font = "700 32px 'Barlow Condensed'"; ctx.fillStyle = "#c8c8d8"; ctx.textBaseline = "middle"; const countryW = ctx.measureText(country).width; const flagW = 56, flagH = 40, gap = 12; const row1Y = 76; const row1Start = W / 2 - ((flag ? flagW + gap : 0) + countryW) / 2; if (flag) ctx.drawImage(flag, row1Start, row1Y - flagH / 2, flagW, flagH); ctx.fillText(country, row1Start + (flag ? flagW + gap : 0), row1Y); // Row 2: player name, white, big, bold const name = String(payload.playerName || "ETTSTATS PLAYER"); ctx.textAlign = "center"; ctx.fillStyle = "#f4f4f5"; ctx.font = "900 60px 'Barlow Condensed'"; ctx.fillText(name, W / 2, 154); // Row 3: ELO, orange, big const elo = Number.isFinite(Number(payload.playerElo)) && Number(payload.playerElo) > 0 ? `${Math.round(Number(payload.playerElo))} ELO` : "— ELO"; ctx.fillStyle = "#f97316"; ctx.font = "900 74px 'Barlow Condensed'"; ctx.fillText(elo, W / 2, 224); // Row 4: rank / NOT RANKED, subtle grey const rank = Number.isFinite(Number(payload.playerRank)) && Number(payload.playerRank) > 0 ? `#${Math.round(Number(payload.playerRank))}` : "NOT RANKED"; ctx.fillStyle = "#8f8f9e"; ctx.font = "600 38px 'Barlow Condensed'"; ctx.fillText(rank, W / 2, 276); // Small footer note requested for provenance. const now = new Date(); const dd = String(now.getDate()).padStart(2, "0"); const mm = String(now.getMonth() + 1).padStart(2, "0"); const yyyy = String(now.getFullYear()); ctx.fillStyle = "#6f6f80"; ctx.font = "500 12px 'Barlow Condensed'"; ctx.fillText(`Generated with ETTSTATS on ${dd}-${mm}-${yyyy}`, W / 2, 300); return canvas; } function shareProfile() { const fallbackUrl = profileShareUrl(); const isMultiplePage = /\/multiple(?:\.html)?\/?$/i.test(window.location.pathname || ""); const copiedMsg = isMultiplePage ? "Profile link copied!" : t("tip_share_copied"); const profileSlug = String(fallbackUrl.split("/").pop() || "") .split("?")[0] .trim() || `player-${currentPlayerId || ""}`; const payload = { playerId: String(currentPlayerId || "").trim(), playerName: String(currentPlayer?.attributes?.["user-name"] || "").trim(), playerElo: Number.isFinite(Number(currentPlayer?.attributes?.elo)) ? Math.round(Number(currentPlayer?.attributes?.elo)) : null, playerRank: Number.isFinite(Number(currentPlayer?.attributes?.rank)) ? Number(currentPlayer?.attributes?.rank) : null, countryCode: String(currentPlayer?.attributes?.["country-code"] || "").trim(), countryName: String(countryName(currentPlayer?.attributes?.["country-code"] || "") || "").trim() }; const onCopied = () => { const btn = document.getElementById("shareBtn"); const icon = document.getElementById("shareBtnIcon"); if (!btn || !icon) return; btn.classList.add("copied"); icon.innerHTML = ''; btn.setAttribute("data-tooltip", copiedMsg); setTimeout(() => { btn.classList.remove("copied"); icon.innerHTML = ''; btn.setAttribute("data-tooltip", t("tip_share")); }, CFG.SHARE_COPY_FEEDBACK_MS); }; const onCopyFailed = () => showCopyToast("Copy failed. Please try again.", false); // Multiple page: copy current combined profile URL only. if (isMultiplePage || !payload.playerId) { const primed = copyTextSyncExecCommand(fallbackUrl); copyTextWithFallback(fallbackUrl).then(ok => { if (ok || primed) { onCopied(); showCopyToast(copiedMsg, true); } else { onCopyFailed(); } }); return; } // Prime clipboard in the same gesture — iPad/Safari won't allow async clipboard after canvas/upload. const clipboardPrimed = copyTextSyncExecCommand(fallbackUrl); // Original app behavior (index): upload profile share image and copy /p/...?...img= token URL. buildProfileShareCanvas(payload) .then(canvas => { const dataUrl = canvas.toDataURL("image/png"); return fetch("share.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: dataUrl, playerId: payload.playerId, cardCode: "PRF", profileSlug }) }); }) .then(r => r.ok ? r.json() : Promise.reject(new Error("profile upload failed"))) .then(data => { const hostedImageUrl = data?.url ? String(data.url) : ""; const fileName = fileNameFromUrl(hostedImageUrl); const imgToken = fileName ? fileName.replace(/\.png$/i, "") : ""; const url = imgToken ? `${fallbackUrl}?img=${encodeURIComponent(imgToken)}` : fallbackUrl; return copyTextWithFallback(url).then(ok => ok ? true : copyTextWithFallback(fallbackUrl)); }) .catch(() => copyTextWithFallback(fallbackUrl)) .then((ok) => { if (ok || clipboardPrimed) { onCopied(); showCopyToast(copiedMsg, true); } else { onCopyFailed(); } }); } function goHome() { profileSection.classList.add("hidden"); landing.classList.remove("hidden"); if (searchInput) searchInput.value = ""; currentPlayerId = null; history.pushState(null, "", location.pathname); updateProfileBackButton(); mvRender(); } function updateProfileBackButton() { const btn = document.getElementById("profileBackBtn"); if (!btn) return; const hasStackBack = Array.isArray(profileBackStack) && profileBackStack.length > 0; const hasExternalBack = !!profileExternalBackUrl; const hasBack = !!currentPlayerId && (hasStackBack || hasExternalBack); btn.classList.toggle("hidden", !hasBack); // On mobile we disable tooltip for the back button. if (window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX) { btn.removeAttribute("data-tooltip"); } else if (!btn.getAttribute("data-tooltip")) { btn.setAttribute("data-tooltip", "Back to previous profile"); } } async function goBackProfile() { if (!currentPlayerId) return; const currentId = String(currentPlayerId); while (profileBackStack.length && String(profileBackStack[profileBackStack.length - 1]) === currentId) { profileBackStack.pop(); } const prev = profileBackStack.pop(); if (!prev) { if (profileExternalBackUrl) { const backUrl = profileExternalBackUrl; profileExternalBackUrl = ""; updateProfileBackButton(); window.location.href = backUrl; return; } updateProfileBackButton(); return; } updateProfileBackButton(); suppressProfileBackPush = true; try { await loadPlayer(String(prev)); } finally { suppressProfileBackPush = false; } } async function openMatchupHistory(oppId, oppName = "", playerContext = null) { const contextPlayerId = playerContext?.id ? String(playerContext.id) : ""; const matchupPlayerId = contextPlayerId || currentPlayerId; if (!matchupPlayerId || !oppId) return; const modal = document.getElementById("matchupModal"); const body = document.getElementById("matchupBody"); const subtitle = document.getElementById("matchupSubtitle"); if (!modal || !body || !subtitle) return; const safeOppName = oppName || "Opponent"; const meName = playerContext?.name || currentPlayer?.attributes?.["user-name"] || "Player"; const meCountry = playerContext?.country || currentPlayer?.attributes?.["country-code"] || null; const meEloOverride = Number(playerContext?.elo ?? NaN); subtitle.textContent = ""; body.innerHTML = `
${t("matchup_loading")}
`; modal.classList.remove("hidden"); try { const getLocalMatchupData = (playerId, otherId) => { if (String(matchupSnapshotPlayerId || "") !== String(playerId || "")) return null; const srcMatches = Array.isArray(matchupMatchesSnapshot) ? matchupMatchesSnapshot : []; const srcIncluded = Array.isArray(matchupIncludedSnapshot) ? matchupIncludedSnapshot : []; if (!srcMatches.length) return null; const filtered = srcMatches.filter(m => { const a = m?.attributes || {}; const isHome = a["home-user-id"] == playerId; const opp = isHome ? (a["away-team"] || [])[0] : (a["home-team"] || [])[0]; return String(opp?.id || "") === String(otherId || ""); }); if (!filtered.length) return null; const keepMatchIds = new Set(filtered.map(m => String(m?.id || "")).filter(Boolean)); const filteredIncluded = srcIncluded.filter(item => { if (item?.type !== "rounds") return false; const mid = String(item?.attributes?.["match-id"] || ""); return keepMatchIds.has(mid); }); return { data: filtered, included: filteredIncluded }; }; // Endpoint-ul matchup este paginat; agregam toate paginile, altfel // totalurile raman blocate la prima pagina (ex: 24/25 meciuri). const fetchMatchupAllPages = async (meId, otherId) => { const cacheKey = `${meId}:${otherId}`; const cached = matchupPairCache[cacheKey] || null; // Start from the first match we don't have yet (incremental). const fromMatchId = cached?.maxId ? cached.maxId + 1 : 0; const fromParam = fromMatchId > 0 ? `&fromMatchId=${fromMatchId}` : ""; let nextUrl = `/matchup/${meId}/${otherId}?page[number]=1&page[size]=${CFG.MATCHUP_PAGE_SIZE}${fromParam}`; const newData = []; const newIncluded = []; const seenMatchIds = new Set((cached?.data || []).map(m => String(m?.id || ""))); const seenIncludedIds = new Set((cached?.included || []).map(i => `${i?.type || ""}:${String(i?.id || "")}`)); let guard = 0; while (nextUrl && guard < CFG.MATCHUP_MAX_PAGES_GUARD) { guard++; const page = await apiViaProxy(nextUrl, 20); const pageData = Array.isArray(page?.data) ? page.data : []; const pageIncluded = Array.isArray(page?.included) ? page.included : []; pageData.forEach(item => { const id = String(item?.id || ""); if (!id || seenMatchIds.has(id)) return; seenMatchIds.add(id); newData.push(item); }); pageIncluded.forEach(item => { const id = `${item?.type || ""}:${String(item?.id || "")}`; if (!id || seenIncludedIds.has(id)) return; seenIncludedIds.add(id); newIncluded.push(item); }); const next = page?.links?.next || null; if (!next) break; nextUrl = apiPathOnly(String(next || "")); if (!nextUrl) break; } // Merge new with cached (new matches are more recent — prepend). const allData = [...newData, ...(cached?.data || [])]; const allIncluded = [...newIncluded, ...(cached?.included || [])]; const newMaxId = allData.reduce((mx, m) => Math.max(mx, Number(m?.id || 0)), cached?.maxId || 0); matchupPairCache[cacheKey] = { data: allData, included: allIncluded, maxId: newMaxId }; return { data: allData, included: allIncluded }; }; const addRealOwnerMetaToMatchup = (src, ownerCtx) => { const ownerId = String(ownerCtx?.id || ""); const ownerName = String(ownerCtx?.name || ""); const ownerCountry = String(ownerCtx?.country || ""); const ownerElo = ownerCtx?.elo ?? ""; return { data: (Array.isArray(src?.data) ? src.data : []).map(item => { const copy = JSON.parse(JSON.stringify(item)); const a = copy.attributes || {}; a["ett-real-owner-id"] = ownerId; a["ett-real-owner-name"] = ownerName; a["ett-real-owner-country"] = ownerCountry; a["ett-real-owner-elo"] = String(ownerElo ?? ""); if (String(a["home-user-id"]) === ownerId) a["home-user-id"] = String(currentPlayerId); if (String(a["away-user-id"]) === ownerId) a["away-user-id"] = String(currentPlayerId); return copy; }), included: Array.isArray(src?.included) ? src.included : [] }; }; const mergeMatchupSources = (a, b) => { const outData = []; const outIncluded = []; const seenMatchIds = new Set(); const seenIncludedIds = new Set(); [a, b].forEach(src => { (Array.isArray(src?.data) ? src.data : []).forEach(item => { const id = String(item?.id || ""); if (!id || seenMatchIds.has(id)) return; seenMatchIds.add(id); outData.push(item); }); (Array.isArray(src?.included) ? src.included : []).forEach(item => { const id = `${item?.type || ""}:${String(item?.id || "")}`; if (!id || seenIncludedIds.has(id)) return; seenIncludedIds.add(id); outIncluded.push(item); }); }); return { data: outData, included: outIncluded }; }; // Combinam mereu snapshot-ul local cu endpoint-ul matchup. // Pentru profilele multiple currentPlayerId este sintetic (ex: multi:...), // deci endpoint-ul remote nu exista. In acel caz folosim snapshot-ul agregat. const isSyntheticMultiProfile = String(currentPlayerId || "").startsWith("multi:"); const isSpecificRealContext = !!contextPlayerId; const localData = isSpecificRealContext ? { data: [], included: [] } : getLocalMatchupData(currentPlayerId, oppId) || { data: [], included: [] }; let remoteData; if (isSyntheticMultiProfile && !isSpecificRealContext) { const remoteMultiSources = await Promise.all( multiAccountContexts.map(async ctx => addRealOwnerMetaToMatchup(await fetchMatchupAllPages(ctx.id, oppId), ctx)) ); remoteData = remoteMultiSources.reduce((acc, src) => mergeMatchupSources(acc, src), { data: [], included: [] }); } else if (isSyntheticMultiProfile && isSpecificRealContext) { remoteData = await fetchMatchupAllPages(matchupPlayerId, oppId); } else { remoteData = await fetchMatchupAllPages(matchupPlayerId, oppId); } const data = mergeMatchupSources(localData, remoteData); const matches = Array.isArray(data?.data) ? data.data : []; const included = Array.isArray(data?.included) ? data.included : []; if (!matches.length) { body.innerHTML = `
${t("matchup_no_matches")}
`; return; } const roundsMap = {}; included.forEach(item => { if (item?.type !== "rounds") return; const a = item.attributes || {}; const mid = String(a["match-id"] || ""); if (!mid) return; if (!roundsMap[mid]) roundsMap[mid] = []; roundsMap[mid].push(a); }); const sorted = [...matches].sort((a, b) => new Date(b?.attributes?.["created-at"] || 0) - new Date(a?.attributes?.["created-at"] || 0)); const realOwnerName = (a) => String(a?.["ett-real-owner-name"] || "").trim(); const realOwnerCountry = (a) => String(a?.["ett-real-owner-country"] || "").trim(); const matchupMeName = meName; const matchupMeCountry = meCountry; let oppCountry = null; let oppElo = null; for (const m of sorted) { const a = m?.attributes || {}; const isHome = a["home-user-id"] == matchupPlayerId; const oppTeam = isHome ? (a["away-team"] || []) : (a["home-team"] || []); const oppEntry = oppTeam[0]; const cc = oppEntry?.["country-code"] || null; if (cc && cc !== "none" && cc !== "Earth" && cc !== "null") { oppCountry = cc; break; } } for (const m of sorted) { const a = m?.attributes || {}; const isHome = a["home-user-id"] == matchupPlayerId; const oppTeam = isHome ? (a["away-team"] || []) : (a["home-team"] || []); const oppEntry = oppTeam[0]; const e = Number(oppEntry?.ELO ?? oppEntry?.elo ?? null); if (Number.isFinite(e) && e > 0) { oppElo = Math.round(e); break; } } const isCompletedByRounds = (mid) => { const rounds = roundsMap[mid] || []; if (!rounds.length) return false; let homeSets = 0, awaySets = 0; rounds.forEach(r => { const hs = Number(r["home-score"] || 0); const as = Number(r["away-score"] || 0); if (!setIsComplete(hs, as)) return; if (hs > as) homeSets++; else awaySets++; }); return homeSets >= 2 || awaySets >= 2; }; let totalWins = 0, rankedWins = 0, rankedTotal = 0, totalCompleted = 0, forfeitedCount = 0; const computeMatchMeta = (m) => { const a = m.attributes || {}; const mid = String(m.id || ""); const isHome = a["home-user-id"] == matchupPlayerId; const ownerName = isSyntheticMultiProfile && !isSpecificRealContext ? (realOwnerName(a) || meName) : meName; const ownerCountry = isSyntheticMultiProfile && !isSpecificRealContext ? (realOwnerCountry(a) || meCountry) : meCountry; const completed = isCompletedByRounds(mid); const won = (a.winner === 0 && isHome) || (a.winner === 1 && !isHome); const eloChange = a["elo-change"] ?? null; const isRanked = a["ranked"] !== false || (eloChange != null && eloChange !== 0); const rawMyScore = Number(isHome ? a["home-score"] : a["away-score"]); const rawOppScore = Number(isHome ? a["away-score"] : a["home-score"]); const dt = new Date(a["created-at"] || Date.now()); const dateLabel = dt.toLocaleDateString(currentLang === "en" ? "en-GB" : undefined, { day: "2-digit", month: "short", year: "2-digit" }); let homeSetsWon = 0, awaySetsWon = 0, totalPts = 0; const sets = (roundsMap[mid] || []) .slice() .sort((r1, r2) => new Date(r1["created-at"] || 0) - new Date(r2["created-at"] || 0)) .map(r => { const hs = Number(r["home-score"] || 0); const as = Number(r["away-score"] || 0); totalPts += hs + as; if (setIsComplete(hs, as)) { if (hs > as) homeSetsWon++; else awaySetsWon++; } const ms = isHome ? hs : as; const os = isHome ? as : hs; return `${ms}-${os}`; }); const setsStr = sets.length ? sets.join(" / ") : ""; const mySetsWon = isHome ? homeSetsWon : awaySetsWon; const oppSetsWon = isHome ? awaySetsWon : homeSetsWon; // In istoricul vechi, campurile home-score/away-score pot fi 0-0 chiar daca seturile sunt complete. // Prioritizam scorul derivat din rounds; folosim scorul brut doar ca fallback. const hasDerivedSetScore = (homeSetsWon + awaySetsWon) > 0; const hasUsableRawScore = Number.isFinite(rawMyScore) && Number.isFinite(rawOppScore) && (rawMyScore > 0 || rawOppScore > 0); const myScore = hasDerivedSetScore ? mySetsWon : (hasUsableRawScore ? rawMyScore : 0); const oppScore = hasDerivedSetScore ? oppSetsWon : (hasUsableRawScore ? rawOppScore : 0); const isAborted = homeSetsWon < 2 && awaySetsWon < 2; const isForfeited = (a["ranked"] === false && a["elo-change"] != null && totalPts === 0) || (eloChange === 0 && totalPts === 0); const isWaived = !!isRanked && isWaivedByRoundsOrRaw(m, roundsMap); return { a, mid, isHome, ownerName, ownerCountry, completed, won, eloChange, isRanked, dateLabel, setsStr, myScore, oppScore, isAborted, isForfeited, isWaived }; }; const isMobileCompact = window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX; // Totaluri H2H: folosim toate meciurile, nu doar primele 25 afisate sorted.forEach(m => { const meta = computeMatchMeta(m); if (meta.completed) { totalCompleted++; if (meta.won) totalWins++; if (meta.isRanked) { rankedTotal++; if (meta.won) rankedWins++; } } else { forfeitedCount++; } }); const rows = sorted.map(m => { const { completed, won, ownerName, ownerCountry, eloChange, isRanked, dateLabel, setsStr, myScore, oppScore, isAborted, isForfeited, isWaived } = computeMatchMeta(m); const ownerLine = isSyntheticMultiProfile && !isSpecificRealContext ? `
${countryFlag(ownerCountry)}${escHtml(ownerName)}
` : ""; const waivedByName = won ? safeOppName : ownerName; const waivedTipHtml = t("tip_waived", `${escHtml(waivedByName)}`); const leftByMe = !won; const leftCls = leftByMe ? "left-me" : "left-opp"; const tagClass = `${isRanked ? "ranked" : "unranked"}${completed ? "" : " muted"}`; let eventHtml = `
`; if (isForfeited) { if (isMobileCompact) { const forfeitedBy = won ? safeOppName : ownerName; eventHtml = `
${escHtml(forfeitedBy)}`)}">ABND
`; } else { const forfeitedBy = won ? safeOppName : ownerName; eventHtml = `
${escHtml(String(forfeitedBy).toUpperCase())} LEFT THE GAME
`; } } else if (isAborted) { if (isMobileCompact) { const abandonedBy = won ? safeOppName : ownerName; eventHtml = `
${escHtml(abandonedBy)}`)}">ABND
`; } else { const abandonedBy = won ? safeOppName : ownerName; eventHtml = `
${escHtml(String(abandonedBy).toUpperCase())} LEFT THE GAME
`; } } let eloImpactHtml = `
`; const hasEloImpact = isRanked && !isForfeited && eloChange !== null && eloChange !== 0; if (hasEloImpact) { const signed = won ? `+${eloChange}` : `-${eloChange}`; eloImpactHtml = `
${isMobileCompact ? signed : `${signed} ELO`}
`; } else if (!isRanked && completed) { eloImpactHtml = `
${isMobileCompact ? (won ? "W" : "L") : (won ? "WIN" : "LOSS")}
`; } const showWaivedTag = isWaived && isRanked; const scoreHtml = showWaivedTag ? `WAIVED` : `${myScore}-${oppScore}`; const setsDisplay = showWaivedTag ? "" : (setsStr || ""); return `
${dateLabel}${ownerLine}
${scoreHtml}
${setsDisplay}
${eventHtml} ${eloImpactHtml}
${isMobileCompact ? (isRanked ? "R" : "C") : (isRanked ? t("matchup_ranked_tag") : t("matchup_unranked_tag"))}
`; }).join(""); const totalPlayed = totalCompleted; const totalLosses = totalCompleted - totalWins; const rankedLosses = rankedTotal - rankedWins; const rankedLeftClass = rankedWins > rankedLosses ? "lead" : rankedWins < rankedLosses ? "trail" : "equal"; const rankedRightClass = rankedLosses > rankedWins ? "lead" : rankedLosses < rankedWins ? "trail" : "equal"; const myEloNow = Number.isFinite(meEloOverride) ? meEloOverride : Number(currentPlayer?.attributes?.elo ?? null); const oldestDateRaw = sorted[sorted.length - 1]?.attributes?.["created-at"] || null; const oldestDate = oldestDateRaw ? new Date(oldestDateRaw).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }) : ""; const meScoreClass = totalWins > totalLosses ? "lead" : totalWins < totalLosses ? "trail" : "equal"; const oppScoreClass = totalLosses > totalWins ? "lead" : totalLosses < totalWins ? "trail" : "equal"; body.innerHTML = `
${countryFlag(matchupMeCountry)}${escHtml(matchupMeName)}
${Number.isFinite(myEloNow) ? `${Math.round(myEloNow)}${isMobileCompact ? "" : " ELO"}` : "—"}
${totalWins}
${t("hof_vs")}
${countryFlag(oppCountry)}${escHtml(safeOppName)}
${Number.isFinite(oppElo) ? `${oppElo}${isMobileCompact ? "" : " ELO"}` : "—"}
${totalLosses}
${oldestDate ? `
since ${oldestDate}
` : ""}
${totalPlayed}
${t("matchup_total_played")}
${forfeitedCount > 0 ? `
${t("matchup_forfeited_note", forfeitedCount)}
` : ""}
${rankedTotal}
${t("matchup_ranked")}
${rankedTotal > 0 ? `
${rankedWins}-${rankedLosses}
` : ""}
${t("matchup_recent")}
${rows}
`; } catch (e) { body.innerHTML = `
${t("matchup_error")}
`; } } function openMatchupFromLocalRivals(oppId, oppName = "") { if (!oppId) return; reopenLocalRivalsOnMatchupClose = true; closeLocalRivalsModal(); openMatchupHistory(oppId, oppName); } function openMatchupHistoryForAccount(playerId, playerName, playerCountry, playerElo, oppId, oppName = "") { if (!playerId || !oppId) return false; openMatchupHistory(oppId, oppName, { id: String(playerId), name: playerName || "Player", country: playerCountry || null, elo: playerElo }); return false; } function openProfileFromMatchup(oppId, oppName = "") { if (!oppId) return false; reopenLocalRivalsOnMatchupClose = false; closeMatchupModal(); const targetId = String(oppId); const targetName = String(oppName || "").trim(); const isMultiplePage = /\/multiple(?:\.html)?\/?$/i.test(window.location.pathname || ""); if (isMultiplePage) { const current = new URL(window.location.href); current.searchParams.set("matchup_opp", targetId); if (targetName) current.searchParams.set("matchup_opp_name", targetName); else current.searchParams.delete("matchup_opp_name"); const basePath = window.location.pathname.replace(/\/multiple(?:\.html)?\/?$/i, "/"); const url = new URL(basePath, window.location.origin); url.searchParams.set("back_to", encodeURIComponent(current.toString())); url.hash = targetId; window.location.href = url.toString(); return false; } loadPlayer(targetId); return false; } function openSignatureVictoryModal() { const modal = document.getElementById("signatureVictoryModal"); const body = document.getElementById("signatureVictoryBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.signatureVictoryTop) ? currentHofStats.signatureVictoryTop : []; signatureVictoryRowsData = rows.slice(); modal.classList.remove("hidden"); const renderSignatureVictoryHeader = () => { const titleEl = modal.querySelector(".modal-title"); if (!titleEl) return; titleEl.innerHTML = `SIGNATURE VICTORY`; }; const renderSignatureVictoryRows = () => { renderSignatureVictoryHeader(); const filtered = signatureVictoryModalState.rankedOnly ? signatureVictoryRowsData.filter(r => !!r.isRanked) : signatureVictoryRowsData.slice(); if (!filtered.length) { body.innerHTML = `
${t("hof_na")}
`; const chkTop = document.getElementById("signatureVictoryRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { signatureVictoryModalState.rankedOnly = !!chkTop.checked; renderSignatureVictoryRows(); }); } return; } const isMob = isMobileViewport(); const htmlRows = filtered.slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const dateLabel = r.date ? formatDate(r.date) : "—"; const opp = oppCellForSignatureModal(r.oppId, r.oppName, r.oppCountry, r.oppElo); const setsScore = (r.sets || []).map(x => `${x.my}-${x.opp}`).join(" / "); const mySets = (r.sets || []).filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = (r.sets || []).filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const meName = currentPlayer?.attributes?.["user-name"] || "Player"; const scoreTipHtml = `${escHtml(meName)} vs ${escHtml(r.oppName || "?")}
${setsScore || t("tip_score")}`; const typeLabel = isMob ? (r.isRanked ? "R" : "C") : (r.isRanked ? "Ranked" : "Casual"); return ` ${idx + 1} ${opp} +${r.diff} ${typeLabel} ${mySets}-${oppSets} ${formatModalMatchEloChangeHtml(r.eloChange)} ${hofDateOwnerCell(dateLabel, r)} `; }).join(""); body.innerHTML = `
${htmlRows}
# ${t("col_opponent")} ${t("col_elo_diff")} ${isMob ? "T" : t("col_type")} ${t("col_score")} ${isMob ? "±" : t("col_match_elo_change")} ${t("col_date")}
`; const chkTop = document.getElementById("signatureVictoryRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { signatureVictoryModalState.rankedOnly = !!chkTop.checked; renderSignatureVictoryRows(); }); } }; renderSignatureVictoryRows(); } function closeSignatureVictoryModal(e) { if (e && e.target !== document.getElementById("signatureVictoryModal")) return; document.getElementById("signatureVictoryModal")?.classList.add("hidden"); } function openUnderdogStingModal() { const modal = document.getElementById("underdogStingModal"); const body = document.getElementById("underdogStingBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.underdogStingTop) ? currentHofStats.underdogStingTop : []; underdogStingRowsData = rows.slice(); modal.classList.remove("hidden"); const renderUnderdogHeader = () => { const titleEl = modal.querySelector(".modal-title"); if (!titleEl) return; titleEl.innerHTML = `UNDERDOG STING`; }; const renderUnderdogRows = () => { renderUnderdogHeader(); const filtered = underdogStingModalState.rankedOnly ? underdogStingRowsData.filter(r => !!r.isRanked) : underdogStingRowsData.slice(); if (!filtered.length) { body.innerHTML = `
${t("hof_na")}
`; const chkTop = document.getElementById("underdogStingRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { underdogStingModalState.rankedOnly = !!chkTop.checked; renderUnderdogRows(); }); } return; } const isMob = isMobileViewport(); const htmlRows = filtered.slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const dateLabel = r.date ? formatDate(r.date) : "—"; const opp = oppCellForSignatureModal(r.oppId, r.oppName, r.oppCountry, r.oppElo); const setsScore = (r.sets || []).map(x => `${x.my}-${x.opp}`).join(" / "); const mySets = (r.sets || []).filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = (r.sets || []).filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const meName = currentPlayer?.attributes?.["user-name"] || "Player"; const scoreTipHtml = `${escHtml(meName)} vs ${escHtml(r.oppName || "?")}
${setsScore || t("tip_score")}`; const typeLabel = isMob ? (r.isRanked ? "R" : "C") : (r.isRanked ? "Ranked" : "Casual"); return ` ${idx + 1} ${opp} -${r.diff} ${typeLabel} ${mySets}-${oppSets} ${formatModalMatchEloChangeHtml(r.eloChange, { asLoss: true })} ${hofDateOwnerCell(dateLabel, r)} `; }).join(""); body.innerHTML = `
${htmlRows}
# ${t("col_opponent")} ${t("col_elo_diff")} ${isMob ? "T" : t("col_type")} ${t("col_score")} ${isMob ? "±" : t("col_match_elo_change")} ${t("col_date")}
`; const chkTop = document.getElementById("underdogStingRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { underdogStingModalState.rankedOnly = !!chkTop.checked; renderUnderdogRows(); }); } }; renderUnderdogRows(); } function closeUnderdogStingModal(e) { if (e && e.target !== document.getElementById("underdogStingModal")) return; document.getElementById("underdogStingModal")?.classList.add("hidden"); } function openBiggestComebackModal() { const modal = document.getElementById("biggestComebackModal"); const body = document.getElementById("biggestComebackBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.biggestComebackTop) ? currentHofStats.biggestComebackTop : []; biggestComebackRowsData = rows.slice(); modal.classList.remove("hidden"); const renderComebackHeader = () => { const titleEl = modal.querySelector(".modal-title"); if (!titleEl) return; titleEl.innerHTML = `${t("hof_comeback")}`; }; const renderComebackRows = () => { renderComebackHeader(); const filtered = biggestComebackRowsData.slice(); if (!filtered.length) { body.innerHTML = `${hofModalRankedScopeSubtitleHtml()}
${t("hof_na")}
`; return; } const htmlRows = filtered.slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const dateLabel = r.date ? formatDate(r.date) : "—"; const opp = oppCellForSignatureModal(r.oppId, r.oppName, r.oppCountry, r.oppElo); const setsScore = (r.sets || []).map(x => `${x.my}-${x.opp}`).join(" / "); const mySets = (r.sets || []).filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = (r.sets || []).filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; return ` ${idx + 1} ${opp} ${r.deficit} ${mySets}-${oppSets}${setsScore ? `
(${setsScore})` : ""} ${hofDateOwnerCell(dateLabel, r)} `; }).join(""); body.innerHTML = `${hofModalRankedScopeSubtitleHtml()}
${htmlRows}
# ${t("col_opponent")} ${t("col_deficit")} ${t("col_score")} ${t("col_date")}
`; }; renderComebackRows(); } function closeBiggestComebackModal(e) { if (e && e.target !== document.getElementById("biggestComebackModal")) return; document.getElementById("biggestComebackModal")?.classList.add("hidden"); } function openMostPlayedOppModal() { const modal = document.getElementById("mostPlayedOppModal"); const body = document.getElementById("mostPlayedOppBody"); if (!modal || !body) return; mostPlayedOppRowsAll = Array.isArray(currentHofStats?.mostPlayedOppTop) ? currentHofStats.mostPlayedOppTop.slice() : []; const minMatches = currentHofScopeKey === "hof_scope_full" ? 5 : 2; const rebuildMostPlayedOppRowsData = () => { mostPlayedOppRowsData = mostPlayedOppRowsAll .filter(r => (mostPlayedOppModalState.rankedOnly ? Number(r?.ranked || 0) : Number(r?.total || 0)) >= minMatches) .map((r, idx) => ({ ...r, baseRank: idx + 1 })); }; rebuildMostPlayedOppRowsData(); modal.classList.remove("hidden"); const titleEl = modal.querySelector(".modal-title"); if (titleEl) titleEl.textContent = t("hof_top_opp"); const sortState = { key: "matches", dir: "desc" }; const getIndicator = (key) => { if (sortState.key !== key) return ""; return sortState.dir === "desc" ? " ↓" : " ↑"; }; const getSortedRows = () => { const arr = mostPlayedOppRowsData.slice(); arr.sort((a, b) => { const nameA = String(a.name || ""); const nameB = String(b.name || ""); if (sortState.key === "rank") { const av = Number(a.baseRank || 0), bv = Number(b.baseRank || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } else if (sortState.key === "opponent") { const cmp = nameA.localeCompare(nameB); if (cmp !== 0) return sortState.dir === "desc" ? -cmp : cmp; } else if (sortState.key === "matches") { const av = Number(a.total || 0), bv = Number(b.total || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } else if (sortState.key === "ranked") { const av = Number(a.ranked || 0), bv = Number(b.ranked || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } else if (sortState.key === "unranked") { const av = Math.max(0, Number(a.total || 0) - Number(a.ranked || 0)); const bv = Math.max(0, Number(b.total || 0) - Number(b.ranked || 0)); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } return nameA.localeCompare(nameB); }); return arr; }; const bindRankedOnly = () => { const chkTop = document.getElementById("mostPlayedOppRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { mostPlayedOppModalState.rankedOnly = !!chkTop.checked; rebuildMostPlayedOppRowsData(); renderMostPlayedOppRows(); }); } }; const scopeAndFilterHtml = () => ``; const renderMostPlayedOppRows = () => { if (!mostPlayedOppRowsData.length) { body.innerHTML = `${scopeAndFilterHtml()}
${t("hof_na")}
`; bindRankedOnly(); return; } const sorted = getSortedRows(); const htmlRows = sorted.slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const opp = oppCellForSignatureModal(r.id, r.name, r.country, r.lastElo); const total = Number(r.total || 0); const ranked = Number(r.ranked || 0); const unranked = Math.max(0, total - ranked); return ` ${idx + 1} ${opp} ${total} ${ranked} ${unranked} `; }).join(""); const isMob = isMobileViewport(); body.innerHTML = `${scopeAndFilterHtml()}
${htmlRows}
`; bindRankedOnly(); body.querySelectorAll(".local-rivals-sort").forEach(btn => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-sort"); if (!["rank", "opponent", "matches", "ranked", "unranked"].includes(String(key || ""))) return; if (sortState.key === key) { sortState.dir = sortState.dir === "desc" ? "asc" : "desc"; } else { sortState.key = key; sortState.dir = key === "opponent" ? "asc" : "desc"; } renderMostPlayedOppRows(); }); }); }; renderMostPlayedOppRows(); } function closeMostPlayedOppModal(e) { if (e && e.target !== document.getElementById("mostPlayedOppModal")) return; document.getElementById("mostPlayedOppModal")?.classList.add("hidden"); } function openLongestMatchModal() { const modal = document.getElementById("longestMatchModal"); const body = document.getElementById("longestMatchBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.longestMatchTop) ? currentHofStats.longestMatchTop : []; longestMatchRowsData = rows.slice(); modal.classList.remove("hidden"); const renderLongestHeader = () => { const titleEl = modal.querySelector(".modal-title"); if (!titleEl) return; titleEl.innerHTML = `LONGEST MATCH`; }; const renderLongestRows = () => { renderLongestHeader(); const getIndicator = (key) => { if (longestMatchModalState.sortKey !== key) return ""; return longestMatchModalState.sortDir === "desc" ? " ↓" : " ↑"; }; const filtered = (longestMatchModalState.rankedOnly ? longestMatchRowsData.filter(r => !!r.isRanked) : longestMatchRowsData.slice()) .filter(r => { const points = Number(r?.pointsPlayed || 0); const durMs = Number(r?.durMs || 0); if (points < 60) return false; if (durMs <= 5 * 60 * 1000) return false; const sets = Array.isArray(r?.sets) ? r.sets : []; return sets.some(s => Number(s?.my || 0) > 11 || Number(s?.opp || 0) > 11); }); filtered.sort((a, b) => { if (longestMatchModalState.sortKey === "duration") { const av = Number(a?.durMs || 0); const bv = Number(b?.durMs || 0); if (av !== bv) return longestMatchModalState.sortDir === "desc" ? (bv - av) : (av - bv); } else { const av = Number(a?.pointsPlayed || 0); const bv = Number(b?.pointsPlayed || 0); if (av !== bv) return longestMatchModalState.sortDir === "desc" ? (bv - av) : (av - bv); } const ad = Number(a?.durMs || 0); const bd = Number(b?.durMs || 0); if (ad !== bd) return bd - ad; return new Date(b?.date || 0) - new Date(a?.date || 0); }); if (!filtered.length) { body.innerHTML = `
${t("hof_na")}
`; const chkTop = document.getElementById("longestMatchRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { longestMatchModalState.rankedOnly = !!chkTop.checked; renderLongestRows(); }); } return; } const isMob = isMobileViewport(); const htmlRows = filtered.slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const dateLabel = r.date ? formatDate(r.date) : "—"; const opp = oppCellForSignatureModal(r.oppId, r.oppName, r.oppCountry, r.oppElo); const pts = Number(r.pointsPlayed || 0); const mins = Math.floor((Number(r.durMs || 0)) / 60000); const secs = String(Math.floor((Number(r.durMs || 0) % 60000) / 1000)).padStart(2, "0"); const dur = `${mins}'${secs}''`; const sets = Array.isArray(r.sets) ? r.sets : []; const mySets = sets.filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = sets.filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const setLines = sets.map(x => `${x.my}-${x.opp}`); const typeLabel = isMob ? (r.isRanked ? "R" : "C") : (r.isRanked ? "Ranked" : "Casual"); return ` ${idx + 1} ${opp} ${pts} ${dur} ${typeLabel} ${mySets}-${oppSets}${setLines.length ? `${setLines.join('')}` : ""} ${hofDateOwnerCell(dateLabel, r)} `; }).join(""); body.innerHTML = `
${htmlRows}
# ${t("col_opponent")} ${isMob ? "T" : t("col_type")} ${t("col_score")} ${t("col_date")}
`; body.querySelectorAll(".local-rivals-sort").forEach(btn => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-sort"); if (key !== "points" && key !== "duration") return; if (longestMatchModalState.sortKey === key) { longestMatchModalState.sortDir = longestMatchModalState.sortDir === "desc" ? "asc" : "desc"; } else { longestMatchModalState.sortKey = key; longestMatchModalState.sortDir = "desc"; } renderLongestRows(); }); }); const chkTop = document.getElementById("longestMatchRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { longestMatchModalState.rankedOnly = !!chkTop.checked; renderLongestRows(); }); } }; renderLongestRows(); } function closeLongestMatchModal(e) { if (e && e.target !== document.getElementById("longestMatchModal")) return; document.getElementById("longestMatchModal")?.classList.add("hidden"); } function openFastFuriousModal() { const modal = document.getElementById("fastFuriousModal"); const body = document.getElementById("fastFuriousBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.fastFuriousTop) ? currentHofStats.fastFuriousTop : []; fastFuriousRowsData = rows.slice(); modal.classList.remove("hidden"); const renderFastHeader = () => { const titleEl = modal.querySelector(".modal-title"); if (!titleEl) return; titleEl.innerHTML = `FAST & FURIOUS`; }; const renderFastRows = () => { renderFastHeader(); const getIndicator = (key) => { if (fastFuriousModalState.sortKey !== key) return ""; return fastFuriousModalState.sortDir === "desc" ? " ↓" : " ↑"; }; const filtered = (fastFuriousModalState.rankedOnly ? fastFuriousRowsData.filter(r => !!r.isRanked) : fastFuriousRowsData.slice()) .filter(r => Number(r?.pointsPlayed || 0) <= 32); filtered.sort((a, b) => { if (fastFuriousModalState.sortKey === "duration") { const av = Number(a?.durMs || 0), bv = Number(b?.durMs || 0); if (av !== bv) return fastFuriousModalState.sortDir === "desc" ? (bv - av) : (av - bv); } else { const av = Number(a?.pointsPlayed || 0), bv = Number(b?.pointsPlayed || 0); if (av !== bv) return fastFuriousModalState.sortDir === "desc" ? (bv - av) : (av - bv); } const ad = Number(a?.durMs || 0), bd = Number(b?.durMs || 0); if (ad !== bd) return ad - bd; return new Date(b?.date || 0) - new Date(a?.date || 0); }); if (!filtered.length) { body.innerHTML = `
${t("hof_na")}
`; const chkTop = document.getElementById("fastFuriousRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { fastFuriousModalState.rankedOnly = !!chkTop.checked; renderFastRows(); }); } return; } const isMob = isMobileViewport(); const htmlRows = filtered.slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const dateLabel = r.date ? formatDate(r.date) : "—"; const opp = oppCellForSignatureModal(r.oppId, r.oppName, r.oppCountry, r.oppElo); const pts = Number(r.pointsPlayed || 0); const mins = Math.floor((Number(r.durMs || 0)) / 60000); const secs = String(Math.floor((Number(r.durMs || 0) % 60000) / 1000)).padStart(2, "0"); const dur = `${mins}'${secs}''`; const sets = Array.isArray(r.sets) ? r.sets : []; const mySets = sets.filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = sets.filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const setsScore = sets.map(x => `${x.my}-${x.opp}`).join(" / "); const typeLabel = isMob ? (r.isRanked ? "R" : "C") : (r.isRanked ? "Ranked" : "Casual"); return ` ${idx + 1} ${opp} ${pts} ${dur} ${typeLabel} ${mySets}-${oppSets}${setsScore ? `
(${setsScore})` : ""} ${hofDateOwnerCell(dateLabel, r)} `; }).join(""); body.innerHTML = `
${htmlRows}
# ${t("col_opponent")} ${isMob ? "T" : t("col_type")} ${t("col_score")} ${t("col_date")}
`; const chkTop = document.getElementById("fastFuriousRankedOnlyTop"); if (chkTop) { chkTop.addEventListener("change", () => { fastFuriousModalState.rankedOnly = !!chkTop.checked; renderFastRows(); }); } body.querySelectorAll(".local-rivals-sort").forEach(btn => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-sort"); if (key !== "points" && key !== "duration") return; if (fastFuriousModalState.sortKey === key) { fastFuriousModalState.sortDir = fastFuriousModalState.sortDir === "desc" ? "asc" : "desc"; } else { fastFuriousModalState.sortKey = key; fastFuriousModalState.sortDir = "asc"; } renderFastRows(); }); }); }; renderFastRows(); } function closeFastFuriousModal(e) { if (e && e.target !== document.getElementById("fastFuriousModal")) return; document.getElementById("fastFuriousModal")?.classList.add("hidden"); } function hofTopDayLimit() { return currentHofScopeKey === "hof_scope_full" ? 10 : 3; } function fmtDurCompact(ms) { const totalMin = Math.max(0, Math.floor(Number(ms || 0) / 60000)); const h = Math.floor(totalMin / 60); const m = totalMin % 60; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } function openMostActiveDayModal() { const modal = document.getElementById("mostActiveDayModal"); const body = document.getElementById("mostActiveDayBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.mostActiveDayTop) ? currentHofStats.mostActiveDayTop : []; mostActiveDayRowsData = rows.slice(); modal.classList.remove("hidden"); const titleEl = modal.querySelector(".modal-title"); if (titleEl) titleEl.textContent = t("hof_active_day"); const getIndicator = (key) => { if (mostActiveDayModalState.sortKey !== key) return ""; return mostActiveDayModalState.sortDir === "desc" ? " ↓" : " ↑"; }; const baseLimited = mostActiveDayRowsData.slice(0, hofTopDayLimit()); const sortedRows = baseLimited.slice().sort((a, b) => { if (mostActiveDayModalState.sortKey === "date") { const av = new Date(String(a?.day || "")).getTime(); const bv = new Date(String(b?.day || "")).getTime(); if (av !== bv) return mostActiveDayModalState.sortDir === "desc" ? (bv - av) : (av - bv); } else if (mostActiveDayModalState.sortKey === "matches") { const av = Number(a?.count || 0); const bv = Number(b?.count || 0); if (av !== bv) return mostActiveDayModalState.sortDir === "desc" ? (bv - av) : (av - bv); } else { const av = Number(a?.durMs || 0); const bv = Number(b?.durMs || 0); if (av !== bv) return mostActiveDayModalState.sortDir === "desc" ? (bv - av) : (av - bv); } return String(b?.day || "").localeCompare(String(a?.day || "")); }); const limited = sortedRows; if (!limited.length) { body.innerHTML = `${hofModalScopeSubtitleHtml()}
${t("hof_na")}
`; return; } const isMob = isMobileViewport(); const hasAccountCol = limited.some(r => Array.isArray(r?.accounts) && r.accounts.length > 0); const htmlRows = limited.map((r, idx) => { const dayLabel = formatDateLocalizedWithTextMonth(String(r.day || "")); const count = Number(r.count || 0); const dur = fmtDurCompact(r.durMs || 0); return ` ${idx + 1} ${formatDateModalCell(dayLabel)} ${count} ${dur} ${hasAccountCol ? `${hofAccountCell(r)}` : ""} `; }).join(""); body.innerHTML = `${hofModalAllMatchesSubtitleHtml()}
${hasAccountCol ? `` : ""} ${htmlRows}
# ${isMob ? "Acc" : "Account"}
`; body.querySelectorAll(".local-rivals-sort").forEach((btn) => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-sort"); if (!["date", "matches", "playtime"].includes(String(key || ""))) return; if (mostActiveDayModalState.sortKey === key) { mostActiveDayModalState.sortDir = mostActiveDayModalState.sortDir === "desc" ? "asc" : "desc"; } else { mostActiveDayModalState.sortKey = key; mostActiveDayModalState.sortDir = "desc"; } openMostActiveDayModal(); }); }); } function closeMostActiveDayModal(e) { if (e && e.target !== document.getElementById("mostActiveDayModal")) return; document.getElementById("mostActiveDayModal")?.classList.add("hidden"); } function openBestDayEverModal() { const modal = document.getElementById("bestDayEverModal"); const body = document.getElementById("bestDayEverBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.bestDayEverTop) ? currentHofStats.bestDayEverTop : []; const positiveRows = rows.filter((r) => Number(r?.net || 0) > 0); bestDayEverRowsData = positiveRows.slice(); modal.classList.remove("hidden"); const titleEl = modal.querySelector(".modal-title"); if (titleEl) titleEl.textContent = t("hof_best_day"); const getIndicator = (key) => { if (bestDayEverModalState.sortKey !== key) return ""; return bestDayEverModalState.sortDir === "desc" ? " ↓" : " ↑"; }; const baseLimited = bestDayEverRowsData.slice(0, hofTopDayLimit()); const limited = baseLimited.slice().sort((a, b) => { if (bestDayEverModalState.sortKey === "date") { const av = new Date(String(a?.date || "")).getTime(); const bv = new Date(String(b?.date || "")).getTime(); if (av !== bv) return bestDayEverModalState.sortDir === "desc" ? (bv - av) : (av - bv); } else if (bestDayEverModalState.sortKey === "wins") { const av = Number(a?.wins || 0); const bv = Number(b?.wins || 0); if (av !== bv) return bestDayEverModalState.sortDir === "desc" ? (bv - av) : (av - bv); } else { const av = Number(a?.net || 0); const bv = Number(b?.net || 0); if (av !== bv) return bestDayEverModalState.sortDir === "desc" ? (bv - av) : (av - bv); } return String(b?.date || "").localeCompare(String(a?.date || "")); }); if (!limited.length) { body.innerHTML = `${hofModalAllRankedMatchesSubtitleHtml()}
${t("hof_na")}
`; return; } const isMob = isMobileViewport(); const hasAccountCol = limited.some(r => Array.isArray(r?.accounts) && r.accounts.length > 0); const htmlRows = limited.map((r, idx) => { const dayLabel = formatDateLocalizedWithTextMonth(String(r.date || "")); const net = Number(r.net || 0); const wins = Number(r.wins || 0); const elo = Number.isFinite(r.playerElo) ? Math.round(r.playerElo) : null; return ` ${idx + 1} ${formatDateModalCell(dayLabel)} ${net >= 0 ? "+" : ""}${net} ${wins} ${elo != null ? elo : "—"} ${hasAccountCol ? `${hofAccountCell(r)}` : ""} `; }).join(""); body.innerHTML = `${hofModalAllMatchesSubtitleHtml()}
${hasAccountCol ? `` : ""} ${htmlRows}
# ${t("col_elo")}${isMob ? "Acc" : "Account"}
`; body.querySelectorAll(".local-rivals-sort").forEach((btn) => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-sort"); if (!["date", "net", "wins"].includes(String(key || ""))) return; if (bestDayEverModalState.sortKey === key) { bestDayEverModalState.sortDir = bestDayEverModalState.sortDir === "desc" ? "asc" : "desc"; } else { bestDayEverModalState.sortKey = key; bestDayEverModalState.sortDir = "desc"; } openBestDayEverModal(); }); }); } function closeBestDayEverModal(e) { if (e && e.target !== document.getElementById("bestDayEverModal")) return; document.getElementById("bestDayEverModal")?.classList.add("hidden"); } function openDieHardModal() { const modal = document.getElementById("dieHardModal"); const body = document.getElementById("dieHardBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.dieHardTop) ? currentHofStats.dieHardTop : []; dieHardRowsData = rows.slice(); modal.classList.remove("hidden"); const titleEl = modal.querySelector(".modal-title"); if (titleEl) titleEl.textContent = t("hof_nemesis"); if (!dieHardRowsData.length) { body.innerHTML = `${hofModalAllMatchesSubtitleHtml()}
${t("hof_na")}
`; return; } const sortState = { key: "matches", dir: "desc" }; const getIndicator = (key) => (sortState.key === key ? (sortState.dir === "desc" ? " ↓" : " ↑") : ""); const getSortedRows = () => { const arr = dieHardRowsData.slice(); arr.sort((a, b) => { if (sortState.key === "date") return 0; if (sortState.key === "opponent") { const cmp = String(a.name || "").localeCompare(String(b.name || "")); return sortState.dir === "desc" ? -cmp : cmp; } if (sortState.key === "matches") { const av = Number(a.total || 0), bv = Number(b.total || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } if (sortState.key === "rate") { const av = Number(a.lossRate || 0), bv = Number(b.lossRate || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } const cmpName = String(a.name || "").localeCompare(String(b.name || "")); if (cmpName !== 0) return cmpName; return Number(b.total || 0) - Number(a.total || 0); }); return arr; }; const renderRows = () => { const htmlRows = getSortedRows().slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const opp = oppCellForSignatureModal(r.id, r.name, r.country, r.lastElo); const rate = `${Math.round(Number(r.lossRate || 0) * 100)}%`; return ` ${idx + 1} ${opp} ${Number(r.total || 0)} ${Number(r.wins || 0)}-${Number(r.losses || 0)} ${rate} `; }).join(""); const isMob = isMobileViewport(); body.innerHTML = `${hofModalAllRankedMatchesSubtitleHtml()}
${htmlRows}
# ${t("col_w_l")}
`; body.querySelectorAll(".local-rivals-sort").forEach(btn => { btn.addEventListener("click", () => { const key = String(btn.getAttribute("data-sort") || ""); if (!["opponent", "matches", "rate"].includes(key)) return; if (sortState.key === key) sortState.dir = sortState.dir === "desc" ? "asc" : "desc"; else { sortState.key = key; sortState.dir = key === "opponent" ? "asc" : "desc"; } renderRows(); }); }); }; renderRows(); } function closeDieHardModal(e) { if (e && e.target !== document.getElementById("dieHardModal")) return; document.getElementById("dieHardModal")?.classList.add("hidden"); } function openFavVictimModal() { const modal = document.getElementById("favVictimModal"); const body = document.getElementById("favVictimBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.favVictimTop) ? currentHofStats.favVictimTop : []; favVictimRowsData = rows.slice(); modal.classList.remove("hidden"); const titleEl = modal.querySelector(".modal-title"); if (titleEl) titleEl.textContent = t("hof_victim"); if (!favVictimRowsData.length) { body.innerHTML = `${hofModalAllRankedMatchesSubtitleHtml()}
${t("hof_na")}
`; return; } const sortState = { key: "matches", dir: "desc" }; const getIndicator = (key) => (sortState.key === key ? (sortState.dir === "desc" ? " ↓" : " ↑") : ""); const getSortedRows = () => { const arr = favVictimRowsData.slice(); arr.sort((a, b) => { if (sortState.key === "opponent") { const cmp = String(a.name || "").localeCompare(String(b.name || "")); return sortState.dir === "desc" ? -cmp : cmp; } if (sortState.key === "matches") { const av = Number(a.total || 0), bv = Number(b.total || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } if (sortState.key === "rate") { const av = Number(a.winRate || 0), bv = Number(b.winRate || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } return String(a.name || "").localeCompare(String(b.name || "")); }); return arr; }; const renderRows = () => { const htmlRows = getSortedRows().slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const opp = oppCellForSignatureModal(r.id, r.name, r.country, r.lastElo); const rate = `${Math.round(Number(r.winRate || 0) * 100)}%`; return ` ${idx + 1} ${opp} ${Number(r.total || 0)} ${Number(r.wins || 0)}-${Number(r.losses || 0)} ${rate} `; }).join(""); const isMob = isMobileViewport(); body.innerHTML = `${hofModalAllRankedMatchesSubtitleHtml()}
${htmlRows}
# ${t("col_w_l")}
`; body.querySelectorAll(".local-rivals-sort").forEach(btn => { btn.addEventListener("click", () => { const key = String(btn.getAttribute("data-sort") || ""); if (!["opponent", "matches", "rate"].includes(key)) return; if (sortState.key === key) sortState.dir = sortState.dir === "desc" ? "asc" : "desc"; else { sortState.key = key; sortState.dir = key === "opponent" ? "asc" : "desc"; } renderRows(); }); }); }; renderRows(); } function closeFavVictimModal(e) { if (e && e.target !== document.getElementById("favVictimModal")) return; document.getElementById("favVictimModal")?.classList.add("hidden"); } function openLongestRivalryModal() { const modal = document.getElementById("longestRivalryModal"); const body = document.getElementById("longestRivalryBody"); if (!modal || !body) return; const rows = Array.isArray(currentHofStats?.longestRivalryTop) ? currentHofStats.longestRivalryTop : []; longestRivalryRowsData = rows.slice(); modal.classList.remove("hidden"); const titleEl = modal.querySelector(".modal-title"); if (titleEl) titleEl.textContent = t("hof_rivalry"); if (!longestRivalryRowsData.length) { body.innerHTML = `${hofModalAllRankedMatchesSubtitleHtml()}
${t("hof_na")}
`; return; } const sortState = { key: "matches", dir: "desc" }; const getIndicator = (key) => (sortState.key === key ? (sortState.dir === "desc" ? " ↓" : " ↑") : ""); const getSortedRows = () => { const arr = longestRivalryRowsData.slice(); arr.sort((a, b) => { if (sortState.key === "opponent") { const cmp = String(a.name || "").localeCompare(String(b.name || "")); return sortState.dir === "desc" ? -cmp : cmp; } if (sortState.key === "matches") { const av = Number(a.total || 0), bv = Number(b.total || 0); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } if (sortState.key === "score") { const av = Math.abs(Number(a.wins || 0) - Number(a.losses || 0)); const bv = Math.abs(Number(b.wins || 0) - Number(b.losses || 0)); if (av !== bv) return sortState.dir === "desc" ? (bv - av) : (av - bv); } return String(a.name || "").localeCompare(String(b.name || "")); }); return arr; }; const renderRows = () => { const htmlRows = getSortedRows().slice(0, HOF_MODAL_MAX_ROWS).map((r, idx) => { const opp = oppCellForSignatureModal(r.id, r.name, r.country, r.lastElo); return ` ${idx + 1} ${opp} ${Number(r.total || 0)} ${Number(r.wins || 0)}-${Number(r.losses || 0)} `; }).join(""); body.innerHTML = `${hofModalAllRankedMatchesSubtitleHtml()}
${htmlRows}
#
`; body.querySelectorAll(".local-rivals-sort").forEach(btn => { btn.addEventListener("click", () => { const key = String(btn.getAttribute("data-sort") || ""); if (!["opponent", "matches", "score"].includes(key)) return; if (sortState.key === key) sortState.dir = sortState.dir === "desc" ? "asc" : "desc"; else { sortState.key = key; sortState.dir = key === "opponent" ? "asc" : "desc"; } renderRows(); }); }); }; renderRows(); } function closeLongestRivalryModal(e) { if (e && e.target !== document.getElementById("longestRivalryModal")) return; document.getElementById("longestRivalryModal")?.classList.add("hidden"); } function hofModalScopeLabel() { if (currentHofScopeKey === "hof_scope_full") { const total = Number(currentHofStats?.totalMatches || 0); if (Number.isFinite(total) && total > 0) { return t("scope_all_matches_n", total.toLocaleString(currentLang === "en" ? "en-US" : undefined)); } return t("scope_all_matches"); } return t("scope_last_100_matches"); } function hofModalScopeSubtitleHtml() { return ``; } function hofModalAllMatchesSubtitleHtml() { const total = Number(currentHofStats?.totalMatches || 0); const txt = (Number.isFinite(total) && total > 0) ? t("scope_all_matches_n", total.toLocaleString(currentLang === "en" ? "en-US" : undefined)) : t("scope_all_matches"); return ``; } function officialRankedMatchTotalFromProfile() { const a = currentPlayer?.attributes; if (!a) return 0; const w = Number(a.wins); const l = Number(a.losses); const sum = (Number.isFinite(w) ? w : 0) + (Number.isFinite(l) ? l : 0); return sum > 0 ? sum : 0; } /** Numitor „of N ranked matches” pentru carduri abandon — aliniat cu cardul RANKED pe full stats. */ function abandonRankedDenominator(s, scopeKey) { if (scopeKey === "hof_scope_full") { const o = officialRankedMatchTotalFromProfile(); if (o > 0) return o; } return Number(s?.totalRanked || 0); } /** * Cifra din \"ALL n RANKED MATCHES\" / \"LAST n RANKED\". * Full: wins+losses oficiale din profil (ca la cardul RANKED); fallback rankedHoFMatchCount. * Quick: acelasi numitor ca la carduri abandon — totalRanked din esantion (isRankedFull), * nu rankedHoFMatchCount (isRanked strict), altfel modalul arata 50 iar abandon 56. */ function hofModalRankedMatchesCount() { if (currentHofScopeKey === "hof_scope_full") { const official = officialRankedMatchTotalFromProfile(); if (official > 0) return official; const n = Number(currentHofStats?.rankedHoFMatchCount ?? currentHofStats?.totalRanked ?? 0); return Number.isFinite(n) ? n : 0; } return Number(currentHofStats?.totalRanked || 0); } function hofModalAllRankedMatchesSubtitleHtml() { const total = hofModalRankedMatchesCount(); const txt = total > 0 ? t("scope_all_ranked_matches_n", total.toLocaleString(currentLang === "en" ? "en-US" : undefined)) : t("scope_all_ranked_matches"); return ``; } function hofModalRankedScopeSubtitleHtml() { if (currentHofScopeKey === "hof_scope_full") return hofModalAllRankedMatchesSubtitleHtml(); const quickRanked = hofModalRankedMatchesCount(); const txt = quickRanked >= 0 ? t("scope_last_ranked_matches_n", quickRanked.toLocaleString(currentLang === "en" ? "en-US" : undefined)) : t("scope_last_ranked_matches"); return ``; } const HOF_MODAL_MAX_ROWS = 100; function oppLinkForModal(id, name, country) { const flagCode = (country && country !== "none" && country !== "Earth" && country !== "null") ? String(country).toLowerCase() : "un"; const flag = ``; const safeNameJs = String(name || "?").replace(/\\/g, "\\\\").replace(/'/g, "\\'"); return id ? `${flag}${escHtml(name || "?")}` : `${flag}${escHtml(name || "?")}`; } function oppCellForSignatureModal(id, name, country, oppElo) { const flagCode = (country && country !== "none" && country !== "Earth" && country !== "null") ? String(country).toLowerCase() : "un"; const flag = ``; const safeName = escHtml(name || "?"); const safeNameJs = String(name || "?").replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const namePart = id ? `${flag}${safeName}` : `${flag}${safeName}`; const eloPart = Number.isFinite(oppElo) ? `
(${Math.round(oppElo)})
` : ""; return `
${namePart}
${eloPart}
`; } function closeMatchupModal(e) { if (e && e.target !== document.getElementById("matchupModal")) return; document.getElementById("matchupModal")?.classList.add("hidden"); if (reopenLocalRivalsOnMatchupClose) { reopenLocalRivalsOnMatchupClose = false; openLocalRivalsModal(); } } async function openLocalRivalsModal() { const modal = document.getElementById("localRivalsModal"); const body = document.getElementById("localRivalsBody"); const subtitle = document.getElementById("localRivalsSubtitle"); if (!modal || !body || !subtitle) return; hydrateLocalRivalsLastOnlineCache(); const rivals = Array.isArray(currentHofStats?.localRivals) ? currentHofStats.localRivals : []; const countryCode = currentHofStats?.localRivalsCountry || currentPlayer?.attributes?.["country-code"] || null; const titleEl = modal.querySelector(".modal-title"); if (titleEl) { const cc = String(countryCode || "").toLowerCase(); const safeCountryName = escHtml(String(countryName(countryCode) || "International").toUpperCase()); const flagSrc = cc && cc !== "none" && cc !== "earth" && cc !== "null" ? `https://flagcdn.com/32x24/${cc}.png` : "https://flagcdn.com/32x24/un.png"; titleEl.innerHTML = `${t("hof_local_rivals")} ${safeCountryName}`; } const meName = currentPlayer?.attributes?.["user-name"] || "This player"; subtitle.innerHTML = `
${escHtml(meName)} has played against ${rivals.length} local opponents.
`; modal.classList.remove("hidden"); if (!rivals.length) { body.innerHTML = `
${t("hof_na")}
`; return; } const PAGE_SIZE_LOCAL_RIVALS = 10; let localRivalsRenderToken = 0; const sortState = { key: (localRivalsModalState?.key === "name" || localRivalsModalState?.key === "elo" || localRivalsModalState?.key === "matches") ? localRivalsModalState.key : "elo", dir: localRivalsModalState?.dir === "asc" ? "asc" : "desc", onlyActive: !!localRivalsModalState?.onlyActive, page: Number(localRivalsModalState?.page || 1) }; const getIndicator = (key) => { if (sortState.key !== key) return ""; return sortState.dir === "desc" ? " ↓" : " ↑"; }; const applySortAndFilter = (rowsData) => { let arr = Array.isArray(rowsData) ? rowsData.slice() : []; if (sortState.onlyActive) { arr = arr.filter(r => { const rid = String(r?.id || ""); const iso = r.lastOnline || localRivalsLastOnlineCache[rid] || null; if (!iso) return false; return inactivityMeta(iso).className === "active-account"; }); } arr.sort((a, b) => { if (sortState.key === "name") { const cmp = String(a.name || "").localeCompare(String(b.name || "")); if (cmp !== 0) return sortState.dir === "desc" ? -cmp : cmp; } else if (sortState.key === "matches") { const av = Number(a.matches || 0); const bv = Number(b.matches || 0); if (bv !== av) return sortState.dir === "desc" ? (bv - av) : (av - bv); } else { const av = Number.isFinite(a.lastElo) ? a.lastElo : -1; const bv = Number.isFinite(b.lastElo) ? b.lastElo : -1; if (bv !== av) return sortState.dir === "desc" ? (bv - av) : (av - bv); } return String(a.name || "").localeCompare(String(b.name || "")); }); return arr; }; const ensureLastOnlineForRows = async (rows) => { const missingIds = Array.from(new Set( (rows || []) .filter(r => { const id = String(r?.id || ""); return id && !r.lastOnline && localRivalsLastOnlineCache[id] === undefined; }) .map(r => String(r.id || "")) )); if (!missingIds.length) return; // At paginated size this is usually <=10 IDs, but keep batching safe. const idChunks = []; for (let i = 0; i < missingIds.length; i += 100) idChunks.push(missingIds.slice(i, i + 100)); await Promise.all(idChunks.map(async (chunk) => { const persistBatch = {}; try { const resp = await apiViaProxy( `/accounts/users?userIdList=${chunk.join(",")}&page[size]=100`, 10 ); const users = Array.isArray(resp?.users) ? resp.users : []; users.forEach(u => { const uid = String(u?.id || ""); const iso = String(u?.lastOnline || "").trim(); if (uid && iso) { localRivalsLastOnlineCache[uid] = iso; persistBatch[uid] = iso; } }); } catch (_) {} persistLocalRivalsLastOnlineEntries(persistBatch); chunk.forEach(rid => { if (localRivalsLastOnlineCache[rid] === undefined) localRivalsLastOnlineCache[rid] = null; }); })); }; const renderPager = (totalRows) => { const pageCount = Math.max(1, Math.ceil(totalRows / PAGE_SIZE_LOCAL_RIVALS)); if (pageCount <= 1) return ""; const page = Math.min(pageCount, Math.max(1, Number(sortState.page || 1))); const mkBtn = (label, targetPage, disabled = false, active = false) => ``; let pagesHtml = ""; const start = Math.max(1, page - 2); const end = Math.min(pageCount, start + 4); for (let p = start; p <= end; p++) pagesHtml += mkBtn(String(p), p, false, p === page); return `
${mkBtn("<<", 1, page <= 1)} ${mkBtn("<", page - 1, page <= 1)} ${pagesHtml} ${mkBtn(">", page + 1, page >= pageCount)} ${mkBtn(">>", pageCount, page >= pageCount)}
`; }; const renderLocalRivalsRows = async (rowsData) => { const renderToken = ++localRivalsRenderToken; const isMobileLocalRivals = window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX; const mobileActivityLabel = (activityClass) => { if (activityClass === "active-account") return "A"; if (activityClass === "inactive-lately") return `+${CFG.INACTIVITY_WARN_DAYS}`; if (activityClass === "inactive-account") return `+${CFG.INACTIVITY_SEMI_DAYS}`; if (activityClass === "dormant") return "+1y"; return "?"; }; const finalRows = applySortAndFilter(rowsData); const pageCount = Math.max(1, Math.ceil(finalRows.length / PAGE_SIZE_LOCAL_RIVALS)); sortState.page = Math.min(pageCount, Math.max(1, Number(sortState.page || 1))); localRivalsModalState = { ...sortState }; const pageStart = (sortState.page - 1) * PAGE_SIZE_LOCAL_RIVALS; const pageEnd = pageStart + PAGE_SIZE_LOCAL_RIVALS; await ensureLastOnlineForRows(finalRows.slice(pageStart, pageEnd)); if (renderToken !== localRivalsRenderToken) return; const rows = finalRows.slice(pageStart, pageEnd).map((r, i) => { const elo = Number.isFinite(r.lastElo) ? String(r.lastElo) : "—"; const iso = r.lastOnline || localRivalsLastOnlineCache[String(r.id || "")] || null; const activityHtml = iso ? (() => { const activity = inactivityMeta(iso); const activityLabel = isMobileLocalRivals ? mobileActivityLabel(activity.className) : activity.label; return `${activityLabel}`; })() : ``; const matches = Number(r.matches || 0); const wins = Number(r.wins || 0); const losses = Number(r.losses || 0); const winrate = matches >= 3 && (wins + losses) > 0 ? `${Math.round((wins / (wins + losses)) * 100)}%` : ""; const rid = String(r.id || ""); const safeNameJs = String(r.name || "?").replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const playerCell = rid ? `${escHtml(r.name || "?")}` : escHtml(r.name || "?"); return ` ${pageStart + i + 1} ${playerCell} ${elo} ${matches} ${wins}-${losses} ${isMobileLocalRivals ? "" : `${winrate}`} ${activityHtml} `; }).join(""); body.innerHTML = `
${isMobileLocalRivals ? "" : ``} ${rows}
# ${t("col_w_l")}${t("col_win_rate")}${t("col_activity")}
${renderPager(finalRows.length)}`; body.querySelectorAll(".local-rivals-sort").forEach(btn => { btn.addEventListener("click", () => { const key = btn.getAttribute("data-sort"); if (key !== "name" && key !== "elo" && key !== "matches") return; if (sortState.key === key) { sortState.dir = sortState.dir === "desc" ? "asc" : "desc"; } else { sortState.key = key; sortState.dir = key === "name" ? "asc" : "desc"; } sortState.page = 1; localRivalsModalState = { ...sortState }; renderLocalRivalsRows(rowsData); }); }); body.querySelectorAll(".local-rivals-page-btn").forEach(btn => { btn.addEventListener("click", () => { const p = Number(btn.getAttribute("data-page") || 1); if (!Number.isFinite(p) || p < 1 || p === sortState.page) return; sortState.page = p; localRivalsModalState = { ...sortState }; renderLocalRivalsRows(rowsData); }); }); }; const mergedInitial = rivals.map(r => ({ ...r, lastOnline: r.lastOnline || localRivalsLastOnlineCache[String(r.id || "")] || null })); let localRivalsRowsData = mergedInitial; const onlyActiveEl = document.getElementById("localRivalsOnlyActive"); if (onlyActiveEl) { onlyActiveEl.checked = !!sortState.onlyActive; onlyActiveEl.addEventListener("change", () => { sortState.onlyActive = !!onlyActiveEl.checked; sortState.page = 1; localRivalsModalState = { ...sortState }; renderLocalRivalsRows(localRivalsRowsData); }); } renderLocalRivalsRows(localRivalsRowsData); } function closeLocalRivalsModal(e) { if (e && e.target !== document.getElementById("localRivalsModal")) return; document.getElementById("localRivalsModal")?.classList.add("hidden"); } function openMatchupFromEloCell(event, el) { if (!el) return; if (window.innerWidth > CFG.MOBILE_BREAKPOINT_PX) return; const oppId = el.getAttribute("data-opp-id") || ""; if (!oppId) return; const oppName = el.getAttribute("data-opp-name") || ""; if (event) { event.preventDefault(); event.stopPropagation(); } openMatchupHistory(oppId, oppName); } function resetProfile() { setApiDataWarning(false); ["heroName","heroCountry","heroOnline", "statElo","statRank","statTrend","statWins","statLosses","statWinRate","statTotal","statRankPct","statUniqueOpp"] .forEach(id => { const el = document.getElementById(id); if (el) el.textContent = "—"; }); const heroFirstActiveEl = document.getElementById("heroFirstActive"); if (heroFirstActiveEl) { heroFirstActiveEl.innerHTML = ""; delete heroFirstActiveEl.dataset.firstActiveIso; } const rankPct = document.getElementById("statRankPct"); if (rankPct) rankPct.textContent = ""; const formCard = document.getElementById("statFormCard"); if (formCard) { formCard.classList.add("hidden"); formCard.classList.remove("trend-up", "trend-down"); } const note = document.getElementById("statTotalNote"); if (note) note.textContent = t("label_ranked_only"); const uniqueCard = document.getElementById("statUniqueOppCard"); if (uniqueCard) uniqueCard.classList.add("hidden"); const elevenBtn = document.getElementById("openElevenBtn"); const clubhouseBtn = document.getElementById("openClubhouseBtn"); if (elevenBtn) elevenBtn.classList.add("hidden"); if (clubhouseBtn) clubhouseBtn.classList.add("hidden"); matchupMatchesSnapshot = []; matchupIncludedSnapshot = []; matchupSnapshotPlayerId = null; closeMatchupModal(); closeLocalRivalsModal(); closeSignatureVictoryModal(); closeUnderdogStingModal(); closeBiggestComebackModal(); closeMostPlayedOppModal(); closeLongestMatchModal(); closeFastFuriousModal(); closeMostActiveDayModal(); closeBestDayEverModal(); closeDieHardModal(); closeFavVictimModal(); closeLongestRivalryModal(); document.getElementById("matchesList").innerHTML = ""; document.getElementById("pagination").innerHTML = ""; document.getElementById("matchesLoading").style.display = "block"; document.getElementById("eloLoading").style.display = "none"; if (eloChart) { eloChart.destroy(); eloChart = null; } eloData = null; eloTitlePointCount = null; updateEloDynamicTitle(); eloLoadSeq++; hofMaxElo = null; lastRankedMatchDate = null; eloAchievedDateMap = {}; hofFullLoading = false; matchPrefetch = null; matchPrefetchId = null; disconnectLazyMatchPrefetchObserver(); matchHybridContext = null; Object.keys(eloHistoryTierByPlayer).forEach((k) => { delete eloHistoryTierByPlayer[k]; }); clearClientFullStatsFetchBundles(); clearProfileMatchesPage1Bootstrap(); resetMatchBannerAggregate(); try { delete window.__combinedRecentBanner; } catch (_) { window.__combinedRecentBanner = null; } matchPage = 1; lastHofStats = null; lastHofScope = null; const peakEl = document.getElementById("statPeakElo"); if (peakEl) peakEl.innerHTML = ""; const hofGridEl = document.getElementById("hofGrid"); if (hofGridEl) { hofGridEl.innerHTML = `
${t("hof_loading")}
`; delete hofGridEl.dataset.hofGridVariant; } const hofCollapse = document.getElementById("hofCollapse"); const hofIcon = document.getElementById("hofIcon"); if (hofCollapse) hofCollapse.classList.add("open"); if (hofIcon) hofIcon.textContent = t("hof_hide_cards"); document.getElementById("hofActions").style.display = "none"; document.getElementById("hofFullBtn").style.display = ""; document.getElementById("hofProgress").style.display = "none"; const hofScope = document.getElementById("hofScope"); hofScope.textContent = t("hof_scope_quick"); hofScope.dataset.scope = "hof_scope_quick"; } // ── TOOLTIPURI CASETE HERO ──────────────────────────────── function applyStatTooltips() { document.querySelector(".stat-card.accent")?.setAttribute("data-tooltip", t("tip_elo")); document.querySelector(".stat-card.accent")?.setAttribute("data-tooltip-pos", "top"); document.querySelector('[id="statRank"]')?.closest(".stat-card")?.setAttribute("data-tooltip-pos", "top"); const _rankVal = currentPlayer?.attributes?.rank; document.querySelector('[id="statRank"]')?.closest(".stat-card")?.setAttribute("data-tooltip", (!_rankVal || _rankVal <= 0) ? t("tip_rank_unranked") : t("tip_rank")); document.querySelector('[id="statWins"]')?.closest(".stat-card")?.setAttribute("data-tooltip-pos", "top"); document.querySelector('[id="statWins"]')?.closest(".stat-card")?.setAttribute("data-tooltip", t("tip_wins")); document.querySelector('[id="statLosses"]')?.closest(".stat-card")?.setAttribute("data-tooltip-pos", "top"); document.querySelector('[id="statLosses"]')?.closest(".stat-card")?.setAttribute("data-tooltip", t("tip_losses")); document.querySelector(".stat-card.highlight")?.setAttribute("data-tooltip-pos", "top"); document.querySelector(".stat-card.highlight")?.setAttribute("data-tooltip", t("tip_winrate")); document.querySelector('[id="statTotal"]')?.closest(".stat-card")?.setAttribute("data-tooltip", t("tip_total")); document.querySelector('[id="statTotal"]')?.closest(".stat-card")?.setAttribute("data-tooltip-pos", "top"); document.querySelector('[id="statRankedPct"]')?.closest(".stat-card")?.setAttribute("data-tooltip", t("tip_ranked_pct")); document.querySelector('[id="statRankedPct"]')?.closest(".stat-card")?.setAttribute("data-tooltip-pos", "top"); document.querySelector('[id="statUniqueOpp"]')?.closest(".stat-card")?.setAttribute("data-tooltip", t("tip_unique_opponents")); document.querySelector('[id="statUniqueOpp"]')?.closest(".stat-card")?.setAttribute("data-tooltip-pos", "top"); document.querySelector('[id="statTrend"]')?.closest(".stat-card")?.setAttribute("data-tooltip-pos", "top"); document.querySelector('[id="statTrend"]')?.closest(".stat-card")?.setAttribute("data-tooltip", t("tip_current_form")); } function updateRankedPct(rankedCount, totalCount) { const el = document.getElementById("statRankedPct"); if (!el) return; if (!totalCount || totalCount === 0) { el.textContent = "—"; return; } el.textContent = Math.min(100, Math.round((rankedCount / totalCount) * 100)) + "%"; } function hideUniqueOppCard() { const card = document.getElementById("statUniqueOppCard"); if (card) card.classList.add("hidden"); } function showUniqueOppCard(uniqueCount) { const card = document.getElementById("statUniqueOppCard"); const valueEl = document.getElementById("statUniqueOpp"); if (!card || !valueEl) return; valueEl.textContent = Number(uniqueCount || 0).toLocaleString(); card.classList.remove("hidden"); } function countUniqueOpponentsFromMatches(matches, included, playerId) { const pid = String(playerId || "").trim(); const unique = new Set(); (Array.isArray(matches) ? matches : []).forEach(m => { const a = m?.attributes || {}; const homeTeam = Array.isArray(a["home-team"]) ? a["home-team"] : []; const awayTeam = Array.isArray(a["away-team"]) ? a["away-team"] : []; homeTeam.forEach(p => { const id = String(p?.id || "").trim(); if (id && id !== pid) unique.add(id); }); awayTeam.forEach(p => { const id = String(p?.id || "").trim(); if (id && id !== pid) unique.add(id); }); const homeUid = String(a["home-user-id"] || "").trim(); const awayUid = String(a["away-user-id"] || "").trim(); if (homeUid === pid && awayUid && awayUid !== pid) unique.add(awayUid); if (awayUid === pid && homeUid && homeUid !== pid) unique.add(homeUid); }); return unique.size; } async function refreshUniqueOppCard(playerId, matchesOpt = null, includedOpt = null) { const pid = String(playerId || "").trim(); hideUniqueOppCard(); if (!/^\d+$/.test(pid)) return; try { if (Array.isArray(matchesOpt) && matchesOpt.length) { const inc = Array.isArray(includedOpt) ? includedOpt : []; const uniqueCount = countUniqueOpponentsFromMatches(matchesOpt, inc, pid); if (String(currentPlayerId || "") === pid) { const firstIso = oldestRecordedMatchIso(matchesOpt); if (firstIso) { firstActiveIsoByPlayer[pid] = firstIso; paintHeroFirstActive(firstIso); } showUniqueOppCard(uniqueCount); if (eloData) renderEloChart(eloData); } return; } const hasResp = await fetch("full-stats-cache-has.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: pid }) }); const hasJson = hasResp.ok ? await hasResp.json().catch(() => null) : null; if (!(hasJson?.ok && hasJson?.hasCache)) return; const cacheJson = await readFullStatsCacheForProfile(pid); if (!(cacheJson?.hasCache && Array.isArray(cacheJson.matches))) return; const uniqueCount = countUniqueOpponentsFromMatches( cacheJson.matches, cacheJson.included, pid ); if (String(currentPlayerId || "") === pid) { const firstIso = oldestRecordedMatchIso(cacheJson.matches); if (firstIso) { firstActiveIsoByPlayer[pid] = firstIso; paintHeroFirstActive(firstIso); } showUniqueOppCard(uniqueCount); if (eloData) renderEloChart(eloData); } } catch (_) {} } function getRankedPopulationTotal() { if (Number.isFinite(rankedPopulationTotal) && rankedPopulationTotal > 0) { return Promise.resolve(rankedPopulationTotal); } if (rankedPopulationPromise) return rankedPopulationPromise; rankedPopulationPromise = fetch("ranked-total.php", { cache: "no-store" }) .then(r => r.ok ? r.json() : null) .then(data => { const total = Number(data?.totalCount || 0); rankedPopulationTotal = Number.isFinite(total) && total > 0 ? total : null; return rankedPopulationTotal; }) .catch(() => null) .finally(() => { rankedPopulationPromise = null; }); return rankedPopulationPromise; } async function renderLandingRankedActive() { const el = document.querySelector(".landing-inner [data-i18n='landing_sub']"); if (!el) return; const base = t("landing_sub"); const total = await getRankedPopulationTotal(); if (!Number.isFinite(total) || total <= 0) { el.textContent = base; return; } const formatted = total.toLocaleString(currentLang === "en" ? "en-US" : undefined); el.innerHTML = `${base} ${t("landing_ranked_active", `${formatted}`)}`; } async function updateRankPercentile(rankValue, playerIdSnapshot) { const el = document.getElementById("statRankPct"); if (!el) return; const rank = Number(rankValue || 0); if (!Number.isFinite(rank) || rank <= 0) { el.textContent = ""; return; } el.innerHTML = "Top ..."; const total = await getRankedPopulationTotal(); if (playerIdSnapshot && currentPlayerId != playerIdSnapshot) return; if (!Number.isFinite(total) || total <= 0 || rank > total) { el.textContent = ""; return; } const pct = (rank / total) * 100; const decimals = pct < 1 ? 2 : 1; el.innerHTML = `Top ${pct.toFixed(decimals)}%`; } function updateCurrentFormCard(matches, playerId) { const card = document.getElementById("statFormCard"); const val = document.getElementById("statTrend"); if (!card || !val) return; const hide = () => { card.classList.add("hidden"); card.classList.remove("trend-up", "trend-down"); val.textContent = "—"; }; if (!Array.isArray(matches) || matches.length === 0 || !playerId) { hide(); return; } const cutoffMs = Date.now() - CFG.FORM_RECENT_DAYS * 24 * 60 * 60 * 1000; const recentRanked = matches.filter(m => { const a = m?.attributes || {}; if (a["ranked"] === false) return false; // Excludem doar forfeited (0-0, fara impact ELO). const hs = Number(a["home-score"] || 0); const as = Number(a["away-score"] || 0); const eloChange = Number(a["elo-change"] ?? NaN); const isForfeitedZero = hs === 0 && as === 0 && Number.isFinite(eloChange) && eloChange === 0; if (isForfeitedZero) return false; const ts = new Date(a["created-at"] || 0).getTime(); return Number.isFinite(ts) && ts >= cutoffMs; }); if (recentRanked.length < CFG.FORM_MIN_MATCHES) { hide(); return; } let wins = 0; recentRanked.forEach(m => { const a = m.attributes || {}; const isHome = a["home-user-id"] == playerId; const won = (a.winner === 0 && isHome) || (a.winner === 1 && !isHome); if (won) wins++; }); const winRate = (wins / recentRanked.length) * 100; if (winRate > CFG.FORM_WIN_UP_PCT) { card.classList.remove("hidden", "trend-down"); card.classList.add("trend-up"); val.textContent = "↗"; return; } if (winRate < CFG.FORM_WIN_DOWN_PCT) { card.classList.remove("hidden", "trend-up"); card.classList.add("trend-down"); val.textContent = "↘"; return; } hide(); } /** Cel mai vechi created-at din lista de meciuri JSON:API */ function oldestRecordedMatchIso(matches) { if (!Array.isArray(matches) || !matches.length) return null; let best = null; let bestTs = Infinity; for (const m of matches) { const iso = m?.attributes?.["created-at"]; if (!iso) continue; const ts = new Date(iso).getTime(); if (Number.isFinite(ts) && ts < bestTs) { bestTs = ts; best = iso; } } return best; } function parseMatchListTotalPages(linksLast) { const m = String(linksLast || "").match(/page%5Bnumber%5D=(\d+)|page\[number\]=(\d+)/); return m ? Math.max(1, parseInt(m[1] || m[2], 10)) : 1; } /** JSON:API — total exact al colectiei, daca providerul il trimite in meta. */ function matchCollectionTotalFromMeta(resp) { const tryMetaObj = (obj) => { if (!obj || typeof obj !== "object") return 0; const keys = ["total", "count", "total-count", "record-count", "totalCount"]; for (let i = 0; i < keys.length; i++) { const k = keys[i]; if (obj[k] == null) continue; const v = Number(obj[k]); if (Number.isFinite(v) && v >= 0) return v; } return 0; }; let v = tryMetaObj(resp?.meta); if (v > 0) return v; if (resp?.meta?.page && typeof resp.meta.page === "object") { v = tryMetaObj(resp.meta.page); if (v > 0) return v; } return 0; } /** * Total meciuri din raspunsul la page[number]=1&page[size]=1&unranked=true. * Cu page[size]=1, indexul ultimei pagini din links.last == numarul total de meciuri * (o intrare pe pagina). Evita cazul „ultima pagina [size=25] plina” cand links.last * nu reflecta inca o pagina partiala (sub-numarare in trepte de pageSize). * Raspuns mic (1 meci + link-uri); eficient fata de prinderea ultimei pagini cu size=25. */ function exactMatchTotalFromSize1Probe(resp) { if (!resp) return 0; const metaTotal = matchCollectionTotalFromMeta(resp); if (metaTotal > 0) return metaTotal; const data = Array.isArray(resp.data) ? resp.data : []; if (!data.length) return 0; const rawLast = resp.links?.last; if (rawLast == null || rawLast === "") return data.length; const m = String(rawLast).match(/page%5Bnumber%5D=(\d+)|page\[number\]=(\d+)/); const lastPage = m ? parseInt(m[1] || m[2], 10) : 0; if (!Number.isFinite(lastPage) || lastPage < 1) return data.length; return lastPage; } /** * Total meciuri din paginare: nu e lastPage x pageSize (ultima pagina poate fi partiala). * Prefera meta; altfel (totalPages-1)*pageSize + items pe ultima pagina. */ function exactMatchTotalFromPages(metaTotal, totalPages, pageSize, firstPageLen, lastPageLen) { const mt = Number(metaTotal); if (Number.isFinite(mt) && mt > 0) return mt; const tp = Math.max(0, Number(totalPages) || 0); const ps = Math.max(1, Number(pageSize) || 25); const f1 = Math.max(0, Number(firstPageLen) || 0); const ln = Math.max(0, Number(lastPageLen) || 0); if (tp <= 1) return f1; if (ln <= 0) return tp * ps; return (tp - 1) * ps + ln; } async function fetchOldestMatchIsoFromFullStatsCache(playerId) { try { const r = await fetch("full-stats-cache-read.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: String(playerId) }) }); const json = await r.json().catch(() => null); if (!json?.ok || !json?.hasCache || !Array.isArray(json.matches) || !json.matches.length) return null; return oldestRecordedMatchIso(json.matches); } catch (_) { return null; } } /** Shared GET for match-count probe (page 1, size 1) — same URL as loadHofQuick countProbe. */ const matchCountProbePage1Resolved = Object.create(null); const matchCountProbePage1Inflight = Object.create(null); async function ensureMatchCountProbePage1(playerId) { const id = String(playerId || "").trim(); if (!/^\d{1,20}$/.test(id)) return null; if (matchCountProbePage1Resolved[id]) return matchCountProbePage1Resolved[id]; if (matchCountProbePage1Inflight[id]) return matchCountProbePage1Inflight[id]; const url = `/accounts/${id}/matches?page[number]=1&page[size]=1&unranked=true`; const inflight = apiViaProxy(url, 20) .then((res) => { matchCountProbePage1Resolved[id] = res; delete matchCountProbePage1Inflight[id]; return res; }) .catch((err) => { delete matchCountProbePage1Inflight[id]; throw err; }); matchCountProbePage1Inflight[id] = inflight; return inflight; } async function fetchOldestMatchIsoFromApi(playerId) { const pid = String(playerId || "").trim(); const probe = await ensureMatchCountProbePage1(pid).catch(() => null); const exactTotal = exactMatchTotalFromSize1Probe(probe); if (!(Number.isFinite(exactTotal) && exactTotal > 0)) return null; const pageLast = await apiViaProxy( `/accounts/${pid}/matches?page[number]=${exactTotal}&page[size]=1&unranked=true`, 18 ).catch(() => null); const rowsL = Array.isArray(pageLast?.data) ? pageLast.data : []; if (!rowsL.length) return null; return rowsL[0]?.attributes?.["created-at"] || null; } function paintHeroFirstActive(iso) { const el = document.getElementById("heroFirstActive"); if (!el) return; if (!iso) { el.innerHTML = ""; delete el.dataset.firstActiveIso; return; } el.dataset.firstActiveIso = iso; const dateStr = formatDateLocalizedWithTextMonth(iso).toUpperCase(); el.innerHTML = `${escHtml(t("label_first_active"))}: ${escHtml(dateStr)}`; } async function refreshHeroFirstActive(playerId) { const pid = String(playerId || "").trim(); if (!/^\d+$/.test(pid)) { paintHeroFirstActive(null); return; } const guard = pid; let iso = firstActiveIsoByPlayer[pid] || null; if (!iso) iso = await fetchOldestMatchIsoFromFullStatsCache(pid); if (!iso) iso = await fetchOldestMatchIsoFromApi(pid); if (iso) firstActiveIsoByPlayer[pid] = iso; if (String(currentPlayerId || "") !== guard) return; paintHeroFirstActive(iso); } /** Hero ELO / rank / W–L / total: same source as initial profile load, without resetting name, country, or current form. */ function applyProfileStatsToHero(p) { const a = p.attributes; document.getElementById("statElo").textContent = a.elo != null ? Math.round(a.elo) : "—"; document.getElementById("statRank").textContent = a.rank > 0 ? `#${a.rank}` : "-"; document.getElementById("statRank").closest(".stat-card")?.classList.toggle("unranked", !(a.rank > 0)); updateRankPercentile(a.rank, p.id); document.getElementById("statWins").textContent = a.wins ?? "—"; document.getElementById("statLosses").textContent = a.losses ?? "—"; document.getElementById("statWinRate").textContent = winRate(a.wins, a.losses); document.getElementById("statTotal").textContent = (a.wins + a.losses) || "0"; applyStatTooltips(); } /** * After the ELO pipeline fetches ranked matches, re-read /accounts so wins+losses (hero total) * match Eleven’s latest aggregate — avoids 98 vs 99 when the first profile response was stale. */ async function refreshHeroStatsFromAccountsApi(playerId) { const pid = String(playerId || "").trim(); if (!/^\d+$/.test(pid)) return; try { const data = await api(`/accounts/${pid}`); const p = data.data?.[0] || data.data; if (!p || String(currentPlayerId || "") !== pid) return; currentPlayer = p; applyProfileStatsToHero(p); const shareBtn = document.getElementById("shareBtn"); if (shareBtn) shareBtn.setAttribute("data-tooltip", t("tip_share")); updateExternalProfileButtons(); } catch (_) { /* best-effort */ } } function renderProfile(p) { const a = p.attributes; const heroNameEl = document.getElementById("heroName"); heroNameEl.textContent = a["user-name"] || "—"; // Scalare font in functie de lungimea numelui const len = (a["user-name"] || "").length; const size = len <= 8 ? 74 : len <= 11 ? 64 : len <= 13 ? 55 : len <= 15 ? 46 : len <= 18 ? 37 : 30; heroNameEl.style.fontSize = size + "px"; document.getElementById("heroCountry").innerHTML = `${countryFlag(a["country-code"])} ${countryName(a["country-code"])}`; paintHeroLastOnline(a); applyProfileStatsToHero(p); updateCurrentFormCard([], currentPlayerId); const shareBtn = document.getElementById("shareBtn"); if (shareBtn) shareBtn.setAttribute("data-tooltip", t("tip_share")); updateExternalProfileButtons(); // Marcam vizual ca datele sunt partiale (doar ranked) pana vine background fetch } // ── ELO HISTORY ─────────────────────────────────────────── function eloChartMaxPointsForViewport() { const desktopMax = Number(CFG?.ELO_CHART_MAX_POINTS) || 150; const isMobile = window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX; return isMobile ? 50 : desktopMax; } function eloCsvLookbackCount() { const n = Number(CFG?.ELO_CSV_LOOKBACK_COUNT); if (Number.isFinite(n) && n >= 50) return Math.floor(n); return Math.max(200, eloChartMaxPointsForViewport() * 3); } /** True when server-side full-stats JSON exists for this player (same gate as rich ELO + Unique Opponents). */ async function fetchFullStatsCacheHas(playerId) { const pid = String(playerId || "").trim(); if (!/^\d+$/.test(pid)) return false; if (Number(CFG.FULL_STATS_STORAGE || 0) !== 1) return false; try { const r = await fetch("full-stats-cache-has.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: pid }) }); if (!r.ok) return false; const j = await r.json().catch(() => null); return !!(j?.ok && j?.hasCache); } catch (_) { return false; } } /** * Debug: set localStorage.setItem("ettstats_debug_ranked","1") then reload, or call * window.__ettstatsDebugRankedCounts() from the console after opening a profile / chart. */ function eloRankedDebugEnabled() { try { if (typeof window !== "undefined" && window.__ETTSTATS_DEBUG_RANKED__) return true; return String(localStorage.getItem("ettstats_debug_ranked") || "") === "1"; } catch (_) { return false; } } function analyzeEloTimelineVsPool(pid, rankedPool) { const rows = Array.isArray(rankedPool) ? rankedPool.slice() : []; const tsOf = (m) => String(m?.attributes?.["created-at"] || "").trim(); rows.sort((a, b) => new Date(tsOf(a)).getTime() - new Date(tsOf(b)).getTime()); const currentEloHint = Number(currentPlayer?.attributes?.elo ?? NaN); let nextPostElo = Number.isFinite(currentEloHint) ? Math.round(currentEloHint) : null; const postByIdx = new Array(rows.length).fill(null); for (let i = rows.length - 1; i >= 0; i--) { const m = rows[i]; const a = m?.attributes || {}; const isHome = String(a["home-user-id"]) === String(pid); const eloChangeNum = Number(a["elo-change"] ?? NaN); const delta = Number.isFinite(eloChangeNum) ? (((a.winner === 0 && isHome) || (a.winner === 1 && !isHome)) ? Math.abs(eloChangeNum) : -Math.abs(eloChangeNum)) : null; const myPreRaw = Number(isHome ? a["home-elo"] : a["away-elo"]); const myPre = Number.isFinite(myPreRaw) ? Math.round(myPreRaw) : null; let post = null; if (Number.isFinite(myPre) && Number.isFinite(delta)) { post = Math.round(myPre + delta); } else if (Number.isFinite(myPre) && !Number.isFinite(delta)) { post = Math.round(myPre); } else if (Number.isFinite(nextPostElo) && Number.isFinite(delta)) { post = Math.round(nextPostElo); } else if (Number.isFinite(nextPostElo) && !Number.isFinite(delta)) { post = Math.round(nextPostElo); } postByIdx[i] = Number.isFinite(post) ? post : null; let preForOlder = null; if (Number.isFinite(myPre)) preForOlder = myPre; else if (Number.isFinite(post) && Number.isFinite(delta)) preForOlder = Math.round(post - delta); nextPostElo = Number.isFinite(preForOlder) ? preForOlder : null; } const gaps = []; rows.forEach((m, i) => { const ts = tsOf(m); const post = postByIdx[i]; if (ts && Number.isFinite(post)) return; const a = m?.attributes || {}; gaps.push({ matchId: String(m?.id || ""), idx: i, missingTs: !ts, missingPost: !Number.isFinite(post), rankedAttr: a["ranked"], eloChange: a["elo-change"], winner: a.winner, homeElo: a["home-elo"], awayElo: a["away-elo"] }); }); return { sortedRows: rows, postByIdx, gaps, timelinePointCount: rows.length - gaps.length }; } function logRankedCountDebug(extra = {}) { const pid = String(currentPlayerId || "").trim(); if (!pid) { console.warn("[ettstats ranked debug] no currentPlayerId"); return; } const a = currentPlayer?.attributes || {}; const officialSum = officialRankedMatchTotalFromProfile(); const wins = Number(a.wins); const losses = Number(a.losses); const pool = eloMarkerMatchesByPlayer[pid] || []; const inc = eloMarkerIncludedByPlayer[pid] || []; const roundsMap = buildRoundsMapFromIncluded(inc); let nStrictRanked = 0; let nPipePass = 0; let nWaived = 0; let nEloZero = 0; let nGateFail = 0; pool.forEach((m) => { const ar = m?.attributes || {}; if (ar["ranked"] !== false) nStrictRanked++; if (isWaivedByRoundsOrRaw(m, roundsMap)) { nWaived++; return; } const eloChangeNum = Number(ar["elo-change"] ?? NaN); if (Number.isFinite(eloChangeNum) && eloChangeNum === 0) { nEloZero++; return; } if (!(ar["ranked"] !== false || (ar["elo-change"] != null && ar["elo-change"] !== 0))) { nGateFail++; return; } nPipePass++; }); const built = buildEloHistoryFromRankedMatches(pid, pool); const builtLen = Array.isArray(built?.data) ? built.data.length : 0; const analysis = analyzeEloTimelineVsPool(pid, pool); const row = { hero_statTotal_wins_plus_losses: officialSum, profile_wins: Number.isFinite(wins) ? wins : null, profile_losses: Number.isFinite(losses) ? losses : null, elo_marker_pool_rows: pool.length, in_pool_strict_ranked_attr: nStrictRanked, pipeline_pass_isRankedMatch: nPipePass, excluded_waived: nWaived, excluded_elo_change_zero: nEloZero, excluded_ranked_gate: nGateFail, buildEloHistory_point_count: builtLen, timeline_computed_ok: analysis.timelinePointCount, chart_final_points_render: extra.finalChartPoints ?? eloTitlePointCount, viewport_cap: eloChartMaxPointsForViewport(), timeline_has_match_ids: extra.timelineHasMatchIds ?? null, raw_points_before_chart_filters: extra.rawIncomingPoints ?? null }; console.groupCollapsed(`[ettstats] ranked count debug · player ${pid}`); console.table(row); if (analysis.gaps.length) { console.warn("[ettstats] timeline gaps (no chart point): first entries", analysis.gaps.slice(0, 12)); } console.info( "[ettstats] Efficiency: hero uses wins+losses from profile (single aggregate, low cost). " + "Chart uses ranked match rows + reconstruction (more work). If numbers diverge, usual causes are " + "API staleness (matches vs profile totals), pipeline filters (waived / zero elo-change), or " + "missing pre-ELO fields blocking reconstruction for oldest rows." ); console.info( "[ettstats] Unification: pick ONE user-visible definition — e.g. show hero career total from profile " + "and label chart \"LAST N visible ranked steps\", OR drive both from full-stats ranked count when loaded." ); console.groupEnd(); } /** Anchor ELO for backward reconstruction — attrs can lag after heavy UI updates; hero card often still shows the value. */ function profileEloHintNumber() { const fromAttr = Number(currentPlayer?.attributes?.elo ?? NaN); if (Number.isFinite(fromAttr)) return fromAttr; try { const el = document.getElementById("statElo"); const txt = String(el?.textContent || "").trim(); if (!txt || txt === "—") return NaN; const cleaned = txt.replace(/[^\d.-]/g, ""); const parsed = Number(cleaned); if (Number.isFinite(parsed)) return Math.round(parsed); } catch (_) {} return NaN; } function buildEloHistoryFromRankedMatches(playerId, rankedMatches, eloHint) { const pid = String(playerId || ""); const rows = Array.isArray(rankedMatches) ? rankedMatches.slice() : []; if (!rows.length) return { data: [] }; const tsOf = (m) => String(m?.attributes?.["created-at"] || "").trim(); rows.sort((a, b) => new Date(tsOf(a)).getTime() - new Date(tsOf(b)).getTime()); const currentEloHint = Number.isFinite(Number(eloHint)) ? Math.round(Number(eloHint)) : profileEloHintNumber(); let nextPostElo = Number.isFinite(currentEloHint) ? Math.round(currentEloHint) : null; const postByIdx = new Array(rows.length).fill(null); for (let i = rows.length - 1; i >= 0; i--) { const m = rows[i]; const a = m?.attributes || {}; const isHome = String(a["home-user-id"]) === pid; const eloChangeNum = Number(a["elo-change"] ?? NaN); const delta = Number.isFinite(eloChangeNum) ? (((a.winner === 0 && isHome) || (a.winner === 1 && !isHome)) ? Math.abs(eloChangeNum) : -Math.abs(eloChangeNum)) : null; const myPreRaw = Number(isHome ? a["home-elo"] : a["away-elo"]); const myPre = Number.isFinite(myPreRaw) ? Math.round(myPreRaw) : null; let post = null; if (Number.isFinite(myPre) && Number.isFinite(delta)) { post = Math.round(myPre + delta); } else if (Number.isFinite(myPre) && !Number.isFinite(delta)) { post = Math.round(myPre); } else if (Number.isFinite(nextPostElo) && Number.isFinite(delta)) { post = Math.round(nextPostElo); } else if (Number.isFinite(nextPostElo) && !Number.isFinite(delta)) { post = Math.round(nextPostElo); } postByIdx[i] = Number.isFinite(post) ? post : null; let preForOlder = null; if (Number.isFinite(myPre)) preForOlder = myPre; else if (Number.isFinite(post) && Number.isFinite(delta)) preForOlder = Math.round(post - delta); nextPostElo = Number.isFinite(preForOlder) ? preForOlder : null; } const points = []; rows.forEach((m, i) => { const ts = tsOf(m); const post = postByIdx[i]; if (!ts || !Number.isFinite(post)) return; points.push({ type: "elo-history-points", id: String(m?.id || `${pid}-${i}`), attributes: { "created-at": ts, "current-elo": Number(post), "match-id": String(m?.id || "") } }); }); return { data: points }; } /** Ranked rows used for ELO timeline reconstruction (same gates as primeEloMarkerMatches / peakFromFullStatsMatches). */ function filterRankedMatchesForEloTimeline(matches, included) { const roundsMap = buildRoundsMapFromIncluded(included); const ranked = []; for (const m of matches || []) { const a = m?.attributes || {}; if (isWaivedByRoundsOrRaw(m, roundsMap)) continue; const eloChangeNum = Number(a["elo-change"] ?? NaN); if (Number.isFinite(eloChangeNum) && eloChangeNum === 0) continue; if (!(a["ranked"] !== false || (a["elo-change"] != null && a["elo-change"] !== 0))) continue; ranked.push(m); } return ranked; } /** * Career peak ELO + date from full-stats style match rows (ranked pipeline aligned with the hero chart). * Optional `eloHint` anchors backward reconstruction when `currentPlayer` is not the same account (e.g. combined profile). */ function peakFromFullStatsMatches(playerId, matches, included, eloHint) { const pid = String(playerId || "").trim(); if (!pid || !Array.isArray(matches) || !matches.length) return null; const ranked = filterRankedMatchesForEloTimeline(matches, included); const data = Number.isFinite(Number(eloHint)) ? buildEloHistoryFromRankedMatches(pid, ranked, eloHint) : buildEloHistoryFromRankedMatches(pid, ranked); const points = Array.isArray(data?.data) ? data.data : []; if (!points.length) return null; let maxVal = -Infinity; let maxDate = null; for (const p of points) { const v = Number(p?.attributes?.["current-elo"] ?? NaN); const d = p?.attributes?.["created-at"] ?? null; if (Number.isFinite(v) && v > maxVal) { maxVal = v; maxDate = d; } } if (!Number.isFinite(maxVal)) return null; return { value: maxVal, date: maxDate }; } window.__appBridge.peakFromFullStatsMatches = peakFromFullStatsMatches; async function loadEloHistory(id) { eloData = null; const pid = String(id || "").trim(); const loadSeq = ++eloLoadSeq; const loading = document.getElementById("eloLoading"); const collapseOpen = document.getElementById("eloCollapse")?.classList?.contains("open"); if (loading) { loading.textContent = t("elo_loading"); loading.style.display = collapseOpen ? "flex" : "none"; } const watchdog = setTimeout(() => { if (loadSeq !== eloLoadSeq) return; if (eloData) return; const collapse = document.getElementById("eloCollapse"); if (!collapse?.classList?.contains("open")) return; const stuck = document.getElementById("eloLoading"); if (!stuck) return; stuck.textContent = t("elo_nodata"); stuck.style.display = "flex"; }, 45000); try { if (String(currentPlayerId || "") !== pid) return; const pendingLocal = clientFullStatsFetchBundleByPlayer[pid]; let data; if (pendingLocal && Array.isArray(pendingLocal.matches) && pendingLocal.matches.length) { if (loadSeq !== eloLoadSeq) return; if (String(currentPlayerId || "") !== pid) return; delete clientFullStatsFetchBundleByPlayer[pid]; eloHistoryTierByPlayer[pid] = "full"; delete eloMarkerLoadPromiseByPlayer[pid]; const ranked = filterRankedMatchesForEloTimeline(pendingLocal.matches, pendingLocal.included); eloMarkerMatchesByPlayer[pid] = ranked; eloMarkerIncludedByPlayer[pid] = Array.isArray(pendingLocal.included) ? pendingLocal.included : []; data = buildEloHistoryFromRankedMatches(pid, ranked); } else { const hasFullCache = await fetchFullStatsCacheHas(pid); if (loadSeq !== eloLoadSeq) return; if (String(currentPlayerId || "") !== pid) return; if (hasFullCache) { eloHistoryTierByPlayer[pid] = "full"; const ranked = await primeEloMarkerMatches(pid); if (loadSeq !== eloLoadSeq) return; if (String(currentPlayerId || "") !== pid) return; data = buildEloHistoryFromRankedMatches(pid, ranked); } else { eloHistoryTierByPlayer[pid] = "csv"; delete eloMarkerMatchesByPlayer[pid]; delete eloMarkerIncludedByPlayer[pid]; const lb = eloCsvLookbackCount(); const csvResp = await api(`/accounts/${pid}/elo-history-csv?lookbackCount=${lb}`, 20).catch(() => null); const pts = Array.isArray(csvResp?.data) ? csvResp.data : []; data = { data: pts }; } } eloData = data; // Full cache: exact career peak from ranked reconstruction. No full cache: best ELO in the CSV window // (last N matches per config — approximate until full stats exist). computeHofMaxElo(data); if (hofMaxElo) cacheSet(`/accounts/${id}/elo-history-max`, hofMaxElo, CACHE_TTL.elo_history); const collapse = document.getElementById("eloCollapse"); if (collapse?.classList?.contains("open")) { document.getElementById("eloLoading").style.display = "none"; renderEloChart(data); } void refreshHeroStatsFromAccountsApi(id); } catch (err) { if (loadSeq !== eloLoadSeq) return; const fail = document.getElementById("eloLoading"); if (fail && document.getElementById("eloCollapse")?.classList?.contains("open")) { fail.textContent = t("elo_nodata"); fail.style.display = "flex"; } } finally { clearTimeout(watchdog); } } /** After quick HOF: load ELO timeline (CSV lookback or full ranked) so hero MAX + chart data are ready without opening the ELO panel. */ function scheduleProfileEloHistory(pid) { const id = String(pid || "").trim(); if (!/^\d+$/.test(id)) return; void (async () => { try { if (String(currentPlayerId || "") !== id) return; await loadEloHistory(id); } catch (_) {} })(); } const eloCrosshairPlugin = { id: "eloCrosshair", afterDraw(chart) { if (!chart.tooltip._active?.length) return; const ctx = chart.ctx; const x = chart.tooltip._active[0].element.x; const topY = chart.scales.y.top; const bottomY = chart.scales.y.bottom; ctx.save(); ctx.beginPath(); ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); ctx.lineWidth = 1; ctx.strokeStyle = "rgba(249,115,22,0.5)"; ctx.setLineDash([4, 4]); ctx.stroke(); ctx.restore(); } }; const eloMinMaxArrowPlugin = { id: "eloMinMaxArrow", afterDatasetsDraw(chart) { try { const cfg = chart?.options?.plugins?.eloMinMaxArrow; if (!cfg) return; const maxIdx = Number(cfg.maxIdx); const minIdx = Number(cfg.minIdx); if (!Number.isFinite(maxIdx) || !Number.isFinite(minIdx)) return; const lineDatasetIdx = chart.data.datasets.findIndex(d => d?.datasetRole === "eloLine"); if (lineDatasetIdx < 0) return; const meta = chart.getDatasetMeta(lineDatasetIdx); const points = meta?.data || []; const drawArrow = (idx, char, color, below = false) => { const pt = points[idx]; if (!pt || !Number.isFinite(pt.x) || !Number.isFinite(pt.y)) return; const ctx = chart.ctx; ctx.save(); ctx.font = "900 18px 'Barlow Condensed', sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = below ? "top" : "bottom"; ctx.fillStyle = color; ctx.strokeStyle = "rgba(0,0,0,0.92)"; ctx.lineWidth = 2.2; const y = below ? (pt.y + 1) : (pt.y - 1); ctx.strokeText(char, pt.x, y); ctx.fillText(char, pt.x, y); ctx.restore(); }; drawArrow(maxIdx, "↑", "#22c55e", false); drawArrow(minIdx, "↓", "#ef4444", true); } catch (_) {} } }; async function readFullStatsCacheForMarkers(playerId) { try { const r = await fetch("full-stats-cache-read.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: String(playerId || "") }) }); if (!r.ok) return { hasCache: false, matches: [], included: [] }; const json = await r.json().catch(() => null); if (!json?.ok || !json?.hasCache) { return { hasCache: false, matches: [], included: [] }; } return { hasCache: true, matches: Array.isArray(json.matches) ? json.matches : [], included: Array.isArray(json.included) ? json.included : [] }; } catch (_) { return { hasCache: false, matches: [], included: [] }; } } async function primeEloMarkerMatches(playerId) { const id = String(playerId || "").trim(); if (!/^\d+$/.test(id)) return []; // Keep one extra ranked match for ELO tooltip left-edge alignment: // chart uses up to N points, while marker-to-point mapping shifts by one. const maxPoints = eloChartMaxPointsForViewport(); // Collect a much larger valid marker pool so waived/zero-impact filtering // doesn't starve older points in the visible tail. const targetRanked = Math.max(maxPoints + 60, maxPoints * 4); if (Array.isArray(eloMarkerMatchesByPlayer[id]) && eloMarkerMatchesByPlayer[id].length >= targetRanked) { return eloMarkerMatchesByPlayer[id]; } if (eloMarkerLoadPromiseByPlayer[id]) return eloMarkerLoadPromiseByPlayer[id]; const pageSize = Number(CFG.MATCH_PAGE_SIZE || 100); const maxPages = 25; // safety cap for profiles with many waived/zero-impact rows // For ELO chart markers we only keep ranked matches with real ELO impact. // Waived 0-0 rows are also excluded even if API provides elo-change. const isRankedMatch = (m, roundsMap = null) => { const a = m?.attributes || {}; if (isWaivedByRoundsOrRaw(m, roundsMap)) return false; const eloChangeNum = Number(a["elo-change"] ?? NaN); if (Number.isFinite(eloChangeNum) && eloChangeNum === 0) return false; return a["ranked"] !== false || (a["elo-change"] != null && a["elo-change"] !== 0); }; const refreshWithLatestRankedPage = async (seedMatches = [], seedIncluded = []) => { const outMatches = Array.isArray(seedMatches) ? seedMatches.slice() : []; const outIncluded = Array.isArray(seedIncluded) ? seedIncluded.slice() : []; const latest = await api(`/accounts/${id}/matches?page[number]=1&page[size]=${pageSize}&ranked=true`).catch(() => null); const latestRows = Array.isArray(latest?.data) ? latest.data : []; const latestInc = Array.isArray(latest?.included) ? latest.included : []; if (!latestRows.length) return { matches: outMatches, included: outIncluded }; const latestRoundsMap = buildRoundsMapFromIncluded(latestInc); const keepLatest = latestRows.filter(m => isRankedMatch(m, latestRoundsMap)); const seen = new Set(outMatches.map(m => String(m?.id || ""))); const prepend = []; keepLatest.forEach((m) => { const mid = String(m?.id || ""); if (!mid || seen.has(mid)) return; seen.add(mid); prepend.push(m); }); return { matches: prepend.concat(outMatches), included: latestInc.concat(outIncluded) }; }; const p = (async () => { const fromCache = await readFullStatsCacheForMarkers(id); if (fromCache.hasCache && Array.isArray(fromCache.matches) && fromCache.matches.length) { const roundsMapFromCache = buildRoundsMapFromIncluded(fromCache.included); const rankedFromCache = fromCache.matches.filter(m => isRankedMatch(m, roundsMapFromCache)); if (rankedFromCache.length >= targetRanked) { const refreshed = await refreshWithLatestRankedPage( rankedFromCache.slice(0, targetRanked), Array.isArray(fromCache.included) ? fromCache.included.slice() : [] ); eloMarkerMatchesByPlayer[id] = refreshed.matches.slice(0, targetRanked); eloMarkerIncludedByPlayer[id] = refreshed.included; return eloMarkerMatchesByPlayer[id]; } } // Use ranked=true so the API returns only ranked matches — smaller payload, fewer pages. const ranked = []; const rankedIncluded = []; for (let page = 1; page <= maxPages; page++) { const data = await api(`/accounts/${id}/matches?page[number]=${page}&page[size]=${pageSize}&ranked=true`).catch(() => null); const rows = Array.isArray(data?.data) ? data.data : []; const inc = Array.isArray(data?.included) ? data.included : []; const roundsMapPage = buildRoundsMapFromIncluded(inc); if (inc.length) rankedIncluded.push(...inc); if (!rows.length) break; rows.forEach(m => { if (isRankedMatch(m, roundsMapPage)) ranked.push(m); }); if (ranked.length >= targetRanked) break; if (rows.length < pageSize) break; } const refreshed = await refreshWithLatestRankedPage( ranked.slice(0, targetRanked), rankedIncluded ); eloMarkerMatchesByPlayer[id] = refreshed.matches.slice(0, targetRanked); eloMarkerIncludedByPlayer[id] = refreshed.included; return eloMarkerMatchesByPlayer[id]; })() .catch(() => { if (!Array.isArray(eloMarkerMatchesByPlayer[id])) eloMarkerMatchesByPlayer[id] = []; if (!Array.isArray(eloMarkerIncludedByPlayer[id])) eloMarkerIncludedByPlayer[id] = []; return eloMarkerMatchesByPlayer[id]; }) .finally(() => { delete eloMarkerLoadPromiseByPlayer[id]; const collapse = document.getElementById("eloCollapse"); if (String(currentPlayerId || "") === id && collapse?.classList?.contains("open") && eloData) { renderEloChart(eloData); } }); eloMarkerLoadPromiseByPlayer[id] = p; return p; } function renderEloChart(data) { const rawIncomingPoints = Array.isArray(data?.data) ? data.data.length : (Array.isArray(data) ? data.length : 0); let points = Array.isArray(data) ? data : (data.data || []); const maxPoints = eloChartMaxPointsForViewport(); const timelineHasMatchIds = points.some((p) => { const mid = p?.attributes?.["match-id"] ?? p?.["match-id"] ?? null; return mid != null && String(mid).trim() !== ""; }); // Normalizam ordinea cronologica (oldest -> newest), indiferent de ordinea API. points = [...points].sort((a, b) => { const da = a?.attributes?.["created-at"] || a?.["created-at"] || a?.date || ""; const db = b?.attributes?.["created-at"] || b?.["created-at"] || b?.date || ""; return new Date(da).getTime() - new Date(db).getTime(); }); // Exclude WAIVED/abandon 0-0 entries from chart timeline. // They do not change ELO and should not appear as chart points. const waivedMinuteKey = (iso) => { const d = new Date(iso); if (!Number.isFinite(d.getTime())) return ""; const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); const hh = String(d.getUTCHours()).padStart(2, "0"); const mm = String(d.getUTCMinutes()).padStart(2, "0"); return `${y}-${m}-${day} ${hh}:${mm}`; }; // IMPORTANT: // For the new ranked-only timeline (points carry match-id), filtering ranked/waived // was already applied upstream in primeEloMarkerMatches/buildEloHistoryFromRankedMatches. // Re-filtering here by timestamp/minute can remove valid ranked matches. if (!timelineHasMatchIds) { const waivedExactTs = new Set(); const waivedMinuteTs = new Set(); const fullWaived = waivedTimelineByPlayer[String(currentPlayerId || "")]; if (fullWaived?.exact instanceof Set) fullWaived.exact.forEach((x) => waivedExactTs.add(x)); if (fullWaived?.minute instanceof Set) fullWaived.minute.forEach((x) => waivedMinuteTs.add(x)); const markerSrc = Array.isArray(eloMarkerMatchesByPlayer[String(currentPlayerId || "")]) ? eloMarkerMatchesByPlayer[String(currentPlayerId || "")] : null; const markerIncluded = Array.isArray(eloMarkerIncludedByPlayer[String(currentPlayerId || "")]) ? eloMarkerIncludedByPlayer[String(currentPlayerId || "")] : []; const snapshotOk = String(matchupSnapshotPlayerId || "") === String(currentPlayerId || ""); const snapshotSrc = snapshotOk && Array.isArray(matchupMatchesSnapshot) ? matchupMatchesSnapshot : []; const snapshotIncluded = snapshotOk && Array.isArray(matchupIncludedSnapshot) ? matchupIncludedSnapshot : []; const waivedSrc = (markerSrc && markerSrc.length) ? markerSrc : snapshotSrc; const waivedSrcIncluded = (markerSrc && markerSrc.length) ? markerIncluded : snapshotIncluded; const waivedRoundsMap = buildRoundsMapFromIncluded(waivedSrcIncluded); // Build casual (non-ranked) blocklist from marker source so we can remove // any casual points that might leak into the legacy elo-history-csv endpoint. const casualExactTs = new Set(); const casualMinuteTs = new Set(); waivedSrc.forEach((m) => { const a = m?.attributes || {}; const ts = String(a["created-at"] || "").trim(); if (!ts) return; const isWaived = isWaivedByRoundsOrRaw(m, waivedRoundsMap); if (isWaived) { waivedExactTs.add(ts); const mk = waivedMinuteKey(ts); if (mk) waivedMinuteTs.add(mk); return; } const eloChangeNum = Number(a["elo-change"] ?? NaN); const positivelyNonRanked = a["ranked"] === false && (!Number.isFinite(eloChangeNum) || eloChangeNum === 0); if (!positivelyNonRanked) return; casualExactTs.add(ts); const mk = waivedMinuteKey(ts); if (mk) casualMinuteTs.add(mk); }); if (waivedExactTs.size || waivedMinuteTs.size || casualExactTs.size || casualMinuteTs.size) { points = points.filter((p) => { const ts = String(p?.attributes?.["created-at"] || p?.["created-at"] || p?.date || "").trim(); if (!ts) return false; if (waivedExactTs.has(ts)) return false; if (casualExactTs.has(ts)) return false; const mk = waivedMinuteKey(ts); if (waivedMinuteTs.has(mk)) return false; if (casualMinuteTs.has(mk)) return false; return true; }); } // Legacy CSV-only cleanup: remove duplicate same-ELO neighbours. if (points.length > 1) { const filtered = []; let prev = null; for (const p of points) { const cur = Number(p?.attributes?.["current-elo"] ?? p?.["current-elo"] ?? NaN); if (!Number.isFinite(cur)) continue; if (prev === null || Math.round(cur) !== Math.round(prev)) { filtered.push(p); } prev = cur; } points = filtered; } } // Limitam la cele mai recente puncte cronologice (desktop/mobile cap). if (points.length > maxPoints) points = points.slice(-maxPoints); eloTitlePointCount = points.length; updateEloDynamicTitle(points.length); if (eloRankedDebugEnabled()) { logRankedCountDebug({ finalChartPoints: points.length, timelineHasMatchIds, rawIncomingPoints }); } if (points.length === 0) { document.getElementById("eloLoading").textContent = t("elo_nodata"); document.getElementById("eloLoading").style.display = "flex"; return; } // Best-effort opponent ELO markers mapped by timestamp from the local snapshot. const buildOpponentMarkers = () => { const pid = String(currentPlayerId || ""); // CSV-only chart (before server full-stats): no opponent dots — snapshot from HOF quick would fake "full" markers. if (eloHistoryTierByPlayer[pid] !== "full") { return { mapped: new Array(points.length).fill(null), rankedTimeline: [] }; } const markerSrc = Array.isArray(eloMarkerMatchesByPlayer[pid]) ? eloMarkerMatchesByPlayer[pid] : null; const markerIncluded = Array.isArray(eloMarkerIncludedByPlayer[pid]) ? eloMarkerIncludedByPlayer[pid] : []; const snapshotOk = String(matchupSnapshotPlayerId || "") === pid; const snapshotSrc = snapshotOk && Array.isArray(matchupMatchesSnapshot) ? matchupMatchesSnapshot : []; const snapshotIncluded = snapshotOk && Array.isArray(matchupIncludedSnapshot) ? matchupIncludedSnapshot : []; const useMarker = !!(markerSrc && markerSrc.length); const src = useMarker ? markerSrc : snapshotSrc; const srcIncluded = useMarker ? markerIncluded : snapshotIncluded; if (!src.length) return { mapped: new Array(points.length).fill(null), rankedTimeline: [] }; const byMatchId = new Map(); const byExact = new Map(); const byMinute = new Map(); const add = (map, key, row) => { if (!key) return; if (!map.has(key)) map.set(key, []); map.get(key).push(row); }; // Normalise a raw timestamp string to unambiguous UTC before any comparison. // The elo-history-csv endpoint often omits the trailing 'Z' on older entries, // causing JS to parse them as LOCAL time instead of UTC. That shifts the UTC // minute key by the browser's timezone offset and breaks minute-level matching. const normTs = (raw) => { if (!raw) return ""; let s = String(raw).trim(); // Normalise space-separated "YYYY-MM-DD HH:MM:SS" → ISO T separator. s = s.replace(/^(\d{4}-\d{2}-\d{2})\s+(\d)/, "$1T$2"); // If no timezone indicator present, assume UTC and append Z. if (s && !/[Zz]$/.test(s) && !/[+\-]\d{2}:?\d{2}$/.test(s)) s += "Z"; return s; }; const minuteKey = (iso) => { const d = new Date(normTs(iso)); if (!Number.isFinite(d.getTime())) return ""; const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); const hh = String(d.getUTCHours()).padStart(2, "0"); const mm = String(d.getUTCMinutes()).padStart(2, "0"); return `${y}-${m}-${day} ${hh}:${mm}`; }; const normalizedRanked = []; const srcRoundsMap = buildRoundsMapFromIncluded(srcIncluded); src.forEach(m => { const a = m?.attributes || {}; const ts = normTs(String(a["created-at"] || "").trim()); if (!ts) return; if (isWaivedByRoundsOrRaw(m, srcRoundsMap)) return; const eloChangeRaw = Number(a["elo-change"] ?? NaN); const isHome = String(a["home-user-id"]) === String(currentPlayerId || ""); const oppEloRaw = Number(isHome ? a["away-elo"] : a["home-elo"]); const oppElo = Number.isFinite(oppEloRaw) ? Math.round(oppEloRaw) : null; const won = (a.winner === 0 && isHome) || (a.winner === 1 && !isHome); const me = isHome ? (a["home-team"] || [])[0] : (a["away-team"] || [])[0]; const opp = isHome ? (a["away-team"] || [])[0] : (a["home-team"] || [])[0]; const myName = me?.UserName || me?.username || currentPlayer?.attributes?.["user-name"] || "Player"; const oppName = opp?.UserName || opp?.username || "?"; const myElo = Number(isHome ? a["home-elo"] : a["away-elo"]); const mySets = Number(isHome ? a["home-score"] : a["away-score"]); const oppSets = Number(isHome ? a["away-score"] : a["home-score"]); // Keep rows even when elo-change is missing/0 in older history; // otherwise large timeline segments lose markers and rich tooltips. const eloDelta = Number.isFinite(eloChangeRaw) ? (won ? Math.abs(eloChangeRaw) : -Math.abs(eloChangeRaw)) : null; const row = { y: oppElo, won, date: ts, matchId: String(m?.id || ""), myName: String(myName), oppName: String(oppName), myElo: Number.isFinite(myElo) ? Math.round(myElo) : null, oppElo, mySets: Number.isFinite(mySets) ? mySets : null, oppSets: Number.isFinite(oppSets) ? oppSets : null, eloDelta }; normalizedRanked.push({ ...row, ts }); if (row.matchId) add(byMatchId, row.matchId, row); add(byExact, ts, row); add(byMinute, minuteKey(ts), row); }); const mapped = points.map(p => { const pointMatchId = String(p?.attributes?.["match-id"] || p?.["match-id"] || "").trim(); if (pointMatchId) { const byId = byMatchId.get(pointMatchId); if (byId && byId.length) return byId.shift(); } const raw = String(p?.attributes?.["created-at"] || p?.["created-at"] || p?.date || "").trim(); if (!raw) return null; const ts = normTs(raw); const exact = byExact.get(ts); if (exact && exact.length) return exact.shift(); const near = byMinute.get(minuteKey(ts)); if (near && near.length) return near.shift(); return null; }); // Fallback for unmatched points: // align sequentially near the chart tail so we can reach the end of the line // even when API timestamps don't perfectly match between datasets. const rankedTimeline = normalizedRanked .slice() .sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); if (!rankedTimeline.length) return { mapped, rankedTimeline: [] }; const rankedTail = rankedTimeline.length > mapped.length ? rankedTimeline.slice(-mapped.length) : rankedTimeline; let r = rankedTail.length - 1; for (let i = mapped.length - 1; i >= 0 && r >= 0; i--) { if (mapped[i]) continue; // Preserve full marker payload (names/ELO/sets/delta/date), not only y/won, // so tooltip rows never degrade to "undefined" on older points. mapped[i] = { ...rankedTail[r] }; r--; } return { mapped, rankedTimeline }; }; const labels = points.map((p, i) => { const d = p.attributes?.["created-at"] || p["created-at"] || p.date; return d ? formatDate(d) : `#${i + 1}`; }); const values = points.map(p => p.attributes?.["current-elo"] ?? p["current-elo"] ?? 0 ); const { mapped: opponentMarkersTimeline, rankedTimeline: rankedTimelineForChart } = buildOpponentMarkers(); // Reconciliere: unele date din elo-history reprezinta ELO inainte de meci. // Daca ELO-ul curent difera, adaugam un punct final pe data ultimului meci ranked // (nu pe data curenta), ca sa pastram timeline-ul corect. const currentElo = currentPlayer?.attributes?.elo; const lastVal = values[values.length - 1]; const lastPointDate = points[points.length - 1]?.attributes?.["created-at"] || points[points.length - 1]?.["created-at"] || points[points.length - 1]?.date || null; const reconcileDate = lastRankedMatchDate || lastPointDate; // For match-id timelines (built directly from ranked matches), the last point is // already the latest ranked match. Do not append synthetic "current ELO" points, // otherwise the newest real match shifts to penultimate position. if (!timelineHasMatchIds && currentElo != null && lastVal != null && Math.round(currentElo) !== Math.round(lastVal) && reconcileDate) { labels.push(formatDate(reconcileDate)); values.push(Math.round(currentElo)); opponentMarkersTimeline.push(null); } // Legacy CSV flow used a one-point shift (point i represented pre-match ELO). // Match-id flow is already aligned 1:1 (point i == match i), so do NOT shift. let opponentMarkersRaw; if (timelineHasMatchIds) { opponentMarkersRaw = opponentMarkersTimeline.slice(); } else { const firstChartTsRaw = String( points[0]?.attributes?.["created-at"] || points[0]?.["created-at"] || points[0]?.date || "" ).trim(); const firstChartMs = firstChartTsRaw ? new Date(firstChartTsRaw).getTime() : NaN; let olderRankedForFirstPoint = null; if (Number.isFinite(firstChartMs) && Array.isArray(rankedTimelineForChart) && rankedTimelineForChart.length) { let bestMs = -Infinity; for (const r of rankedTimelineForChart) { const tms = new Date(r.ts).getTime(); if (!Number.isFinite(tms)) continue; if (tms < firstChartMs && tms > bestMs) { bestMs = tms; olderRankedForFirstPoint = r; } } } opponentMarkersRaw = opponentMarkersTimeline.map((_, i) => { if (i > 0) return opponentMarkersTimeline[i - 1]; // Left edge: use the actual older ranked match (real data, not copied). return olderRankedForFirstPoint || opponentMarkersTimeline[0] || null; }); } // Gasim indicii pentru max si min const maxVal = Math.max(...values); const minVal = Math.min(...values); const maxIdx = values.indexOf(maxVal); const minIdx = values.indexOf(minVal); // Puncte colorate per index pentru max/min const pointColors = values.map((v, i) => { return "#f97316"; }); const pointRadii = values.map((v, i) => { return points.length > CFG.ELO_CHART_HIDE_DOTS ? 0 : 3; }); const markerValues = opponentMarkersRaw.map(m => (m && Number.isFinite(m.y) ? m.y : null)); const markerColors = opponentMarkersRaw.map(m => (m ? (m.won ? "rgba(34,197,94,0.48)" : "rgba(239,68,68,0.48)") : "rgba(0,0,0,0)")); const markerRotations = opponentMarkersRaw.map(m => (m ? (m.won ? 0 : 180) : 0)); const lineOutcomeColors = values.map((myElo, i) => { const mark = opponentMarkersRaw[i]; const preMatchElo = i > 0 ? Number(values[i - 1]) : Number(myElo); if (!mark || !Number.isFinite(mark.y) || !Number.isFinite(preMatchElo)) return "rgba(0,0,0,0)"; if (mark.won && mark.y > preMatchElo) return "#22c55e"; // upset win if (!mark.won && mark.y < preMatchElo) return "#ef4444"; // upset loss return "rgba(0,0,0,0)"; // expected result hidden }); const ctx = document.getElementById("eloChart").getContext("2d"); const gradient = ctx.createLinearGradient(0, 0, 0, 200); gradient.addColorStop(0, "rgba(249,115,22,0.35)"); gradient.addColorStop(1, "rgba(249,115,22,0)"); // Curatam pastilele vechi document.querySelectorAll(".elo-badge").forEach(el => el.remove()); if (eloBadgesTimer) { clearTimeout(eloBadgesTimer); eloBadgesTimer = null; } const isMobile = window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX; const getOrCreateEloTooltipEl = (chart) => { const parent = chart.canvas.parentNode; let el = parent.querySelector(".elo-chart-tooltip"); if (el) return el; el = document.createElement("div"); el.className = "elo-chart-tooltip"; el.style.position = "absolute"; el.style.pointerEvents = "none"; el.style.transform = "none"; el.style.background = "#1c1c21"; el.style.border = "1px solid #f97316"; el.style.borderRadius = "10px"; el.style.padding = "5px 6px"; el.style.fontFamily = "var(--font-body)"; el.style.fontSize = "12px"; el.style.lineHeight = "1.3"; el.style.color = "#e8e8ee"; el.style.whiteSpace = "normal"; el.style.maxWidth = "320px"; el.style.boxShadow = "0 8px 20px rgba(0,0,0,.35)"; el.style.zIndex = "20"; el.style.opacity = "0"; el.style.transition = "opacity .08s ease"; parent.appendChild(el); return el; }; eloChart = new Chart(ctx, { type: "line", data: { labels, datasets: [{ datasetRole: "eloLine", data: values, borderColor: "#8a94a6", backgroundColor: gradient, borderWidth: 1.5, order: 20, pointRadius: pointRadii, pointHoverRadius: 6, pointHoverBackgroundColor: "#f97316", pointHoverBorderColor: "#fff", pointHoverBorderWidth: 2, pointBackgroundColor: pointColors, tension: 0.3, fill: true, }, { datasetRole: "lineOutcome", data: values, type: "line", showLine: false, order: 1, pointStyle: "circle", pointRadius: 2.8, pointHoverRadius: 4, pointHitRadius: 5, pointBackgroundColor: lineOutcomeColors, pointBorderColor: lineOutcomeColors, pointBorderWidth: 1.1, spanGaps: true }, { datasetRole: "oppMarker", data: markerValues, type: "line", showLine: false, order: 0, pointStyle: "triangle", pointRadius: 2.7, pointHoverRadius: 4.2, pointHitRadius: 8, pointRotation: markerRotations, pointBackgroundColor: markerColors, pointBorderColor: markerColors, pointBorderWidth: 0, spanGaps: true }] }, plugins: [eloCrosshairPlugin, eloMinMaxArrowPlugin], options: { responsive: true, maintainAspectRatio: false, layout: { padding: { left: 0, right: isMobile ? 22 : 18, top: 8, bottom: isMobile ? 18 : 0 } }, interaction: { mode: "index", intersect: false }, plugins: { legend: { display: false }, eloMinMaxArrow: { maxIdx, minIdx }, tooltip: { enabled: false, external: ({ chart, tooltip }) => { const elTip = getOrCreateEloTooltipEl(chart); if (!tooltip || tooltip.opacity === 0 || !tooltip.dataPoints?.length) { elTip.style.opacity = "0"; return; } const linePoint = tooltip.dataPoints.find(dp => dp?.dataset?.datasetRole === "eloLine") || tooltip.dataPoints[0]; const idx = Number(linePoint?.dataIndex ?? -1); if (idx < 0) { elTip.style.opacity = "0"; return; } const mark = opponentMarkersRaw[idx]; if (!mark) { const dateText = escHtml(labels[idx] || ""); const eloText = Number.isFinite(values[idx]) ? Math.round(values[idx]) : "—"; elTip.innerHTML = `
${dateText}
player ELO: ${eloText}
`; } else { const delta = Number(mark.eloDelta); const hasDelta = Number.isFinite(delta); const deltaTxt = hasDelta ? `${delta > 0 ? "+" : ""}${Math.round(delta)} ELO` : "ELO n/a"; const deltaColor = !hasDelta ? "#9a9aaa" : (delta >= 0 ? "#22c55e" : "#ef4444"); const dateText = escHtml(formatDate(mark.date || labels[idx])); const myEloTxt = Number.isFinite(mark.myElo) ? Math.round(mark.myElo) : "—"; const oppEloTxt = Number.isFinite(mark.oppElo) ? Math.round(mark.oppElo) : "—"; const mySetsTxt = Number.isFinite(mark.mySets) ? String(mark.mySets) : "—"; const oppSetsTxt = Number.isFinite(mark.oppSets) ? String(mark.oppSets) : "—"; const myNameTxt = escHtml(String(mark.myName || currentPlayer?.attributes?.["user-name"] || "Player")); const oppNameTxt = escHtml(String(mark.oppName || "?")); elTip.innerHTML = `
${dateText} ${escHtml(deltaTxt)}
${myNameTxt} (${myEloTxt}) ${mySetsTxt}
${oppNameTxt} (${oppEloTxt}) ${oppSetsTxt}
`; } const area = chart.chartArea || { left: 0, right: chart.width, top: 0, bottom: chart.height }; const pad = 8; const gapRight = 48; // tooltip shown to the right of pointer const gapLeft = 0; // tooltip shown to the left of pointer const caretX = Number(tooltip.caretX || 0); const caretY = Number(tooltip.caretY || 0); const areaWidth = Math.max(160, Math.round((area.right || 0) - (area.left || 0))); const maxW = Math.max(180, Math.min(320, areaWidth - 10)); elTip.style.maxWidth = `${maxW}px`; // Measure after content + max-width are applied. const tipW = Math.ceil(elTip.offsetWidth || 240); const tipH = Math.ceil(elTip.offsetHeight || 80); // Always keep tooltip on one side of the pointer: // left half -> right side; right half -> left side. const midX = area.left + areaWidth / 2; const rightCandidate = caretX + gapRight; const leftCandidate = caretX - tipW - gapLeft; let x = caretX < midX ? rightCandidate : leftCandidate; // If preferred side doesn't fit, flip side. const fitsRight = rightCandidate + tipW <= area.right - pad; const fitsLeft = leftCandidate >= area.left + pad; if (caretX < midX && !fitsRight && fitsLeft) x = leftCandidate; if (caretX >= midX && !fitsLeft && fitsRight) x = rightCandidate; // Final clamp to visible bounds. x = Math.max(area.left + pad, Math.min(x, area.right - tipW - pad)); // Safety: if tooltip would still overlap pointer horizontally, nudge it away. if (x <= caretX && caretX <= (x + tipW)) { const pushRight = caretX + gapRight; const pushLeft = caretX - tipW - gapLeft; if (pushRight + tipW <= area.right - pad) x = pushRight; else if (pushLeft >= area.left + pad) x = pushLeft; } // Keep tooltip vertically aligned with pointer, but always fully visible. let y = caretY - (tipH / 2); y = Math.max(area.top + pad, Math.min(y, area.bottom - tipH - pad)); elTip.style.left = `${Math.round(x)}px`; elTip.style.top = `${Math.round(y)}px`; elTip.style.opacity = "1"; }, backgroundColor: "#1c1c21", borderColor: "#f97316", borderWidth: 1, titleColor: "#f97316", titleFont: { size: 12, weight: "600" }, bodyColor: "#e8e8ee", bodyFont: { size: 13, weight: "600" }, padding: { x: 12, y: 8 }, displayColors: false, itemSort: (a, b) => { const roleA = a?.dataset?.datasetRole || ""; const roleB = b?.dataset?.datasetRole || ""; const rank = (role) => { if (role === "eloLine") return 0; if (role === "oppMarker") return 1; if (role === "lineOutcome") return 2; return 3; }; return rank(roleA) - rank(roleB); }, callbacks: { title: items => labels[items[0].dataIndex], label: item => { const role = item?.dataset?.datasetRole || ""; if (role === "oppMarker") { const mark = opponentMarkersRaw[item.dataIndex]; if (!mark || !Number.isFinite(mark.y)) return ""; return `${mark.won ? "win" : "loss"} vs opp: ${Math.round(mark.y)} ELO`; } if (role === "lineOutcome") return ""; return `player ELO: ${Math.round(item.raw)}`; }, } } }, scales: { x: { display: window.innerWidth > CFG.MOBILE_BREAKPOINT_PX, afterFit(scale) { if (window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX) scale.height = 0; }, ticks: { color: "#6a6a7a", font: { size: 10 }, maxRotation: 0, autoSkip: false, callback: function(val, index) { const compactTick = (lbl) => { if (!lbl) return ""; const p = String(lbl).split(" "); if (p.length >= 3) return `${p[0]} ${p[1]} '${p[2].slice(-2)}`; return lbl; }; const step = Math.max(1, Math.floor(points.length / 5)); const lastIdx = points.length - 1; if (index === 0 || index === lastIdx) { return compactTick(labels[index]); } // Avoid label overlap near the right edge (last tick has priority). const tooCloseToEnd = (lastIdx - index) <= Math.ceil(step * 0.6); if (!tooCloseToEnd && index % step === 0) { return compactTick(labels[index]); } return ""; } }, grid: { color: "#1c1c21" } }, y: { display: window.innerWidth > CFG.MOBILE_BREAKPOINT_PX, afterFit(scale) { if (window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX) scale.width = 0; }, ticks: { color: "#9a9aaa", font: { size: 11 } }, grid: { color: "#1c1c21" } } } } }); // Badges HTML after chart animation; guard against stale timers from prior chart instances. const chartInstance = eloChart; eloBadgesTimer = setTimeout(() => { if (eloChart !== chartInstance) return; renderEloBadges(chartInstance, maxIdx, maxVal, minIdx, minVal, labels, isMobile); eloBadgesTimer = null; }, CFG.ELO_BADGES_DELAY_MS); } function renderEloBadges(chart, maxIdx, maxVal, minIdx, minVal, labels, isMobile) { document.querySelectorAll(".elo-badge").forEach(el => el.remove()); if (!chart || !chart.scales || !chart.scales.x || !chart.scales.y) return; const wrap = document.querySelector(".chart-wrap"); if (!wrap) return; // "18 Apr 2026" → "Apr '26" const shortDate = (lbl) => { if (!lbl) return ""; const parts = lbl.split(" "); return parts.length >= 3 ? `${parts[1]} '${parts[2].slice(-2)}` : lbl; }; const addTopTagAtIndex = (idx, text, color, topPx) => { const px = chart.scales.x.getPixelForValue(idx); const canvasRect = chart.canvas.getBoundingClientRect(); const wrapRect = wrap.getBoundingClientRect(); let left = px + (canvasRect.left - wrapRect.left); const tag = document.createElement("div"); tag.className = "elo-badge"; tag.textContent = text; tag.style.cssText = [ "position:absolute", `left:${left}px`, `top:${topPx}px`, "transform:translateX(-50%)", "background:#111827", "color:#fff", `border:1px solid ${color}`, "font:700 11px/1.2 'Barlow',sans-serif", "padding:3px 8px", "border-radius:6px", "pointer-events:none", "z-index:12", "white-space:nowrap", "text-align:center" ].join(";"); const pointer = document.createElement("div"); pointer.style.cssText = [ "position:absolute", "left:50%", "bottom:-6px", "transform:translateX(-50%)", "width:0;height:0", "border-left:5px solid transparent", "border-right:5px solid transparent", `border-top:6px solid ${color}` ].join(";"); tag.appendChild(pointer); wrap.appendChild(tag); const r = tag.getBoundingClientRect(); const wr = wrap.getBoundingClientRect(); if (r.left < wr.left + 4) { left += (wr.left + 4) - r.left; tag.style.left = `${left}px`; } else if (r.right > wr.right - 4) { left -= r.right - (wr.right - 4); tag.style.left = `${left}px`; } }; if (Number.isFinite(maxVal) && Number.isFinite(minVal)) { const topMax = 8; let topMin = 8; const totalPoints = Array.isArray(labels) ? labels.length : 0; if (Math.abs(maxIdx - minIdx) <= Math.max(2, Math.floor(totalPoints * 0.08))) { topMin = 30; } addTopTagAtIndex(maxIdx, `MAX ${Math.round(maxVal)}`, "#22c55e", topMax); addTopTagAtIndex(minIdx, `MIN ${Math.round(minVal)}`, "#ef4444", topMin); } // Min/max arrows are rendered by plugin on canvas (not HTML), // so tooltip naturally overlays them and positions stay exact on the line. // Pe mobil: date range la baza graficului (prima si ultima data) if (isMobile && labels && labels.length > 0) { const addRangeLabel = (text, side) => { const el = document.createElement("div"); el.className = "elo-badge"; el.textContent = text; el.style.cssText = [ "position:absolute", side === "left" ? "left:8px" : "right:8px", "bottom:4px", "font:600 10px/1 'Barlow',sans-serif", "color:#6a6a7a", "pointer-events:none", "z-index:10", "white-space:nowrap" ].join(";"); wrap.appendChild(el); }; addRangeLabel(shortDate(labels[0]), "left"); addRangeLabel(shortDate(labels[labels.length - 1]), "right"); } } // ── MECIURI ─────────────────────────────────────────────── /** Resolved JSON:API response for page 1 (MATCH_PAGE_SIZE) — shared by HOF quick + Recent Matches. */ const profileMatchesPage1Resolved = Object.create(null); const profileMatchesPage1Inflight = Object.create(null); function clearProfileMatchesPage1Bootstrap(playerId) { if (playerId != null && playerId !== "") { const k = String(playerId); delete profileMatchesPage1Resolved[k]; delete profileMatchesPage1Inflight[k]; delete matchCountProbePage1Resolved[k]; delete matchCountProbePage1Inflight[k]; return; } Object.keys(profileMatchesPage1Resolved).forEach((k) => { delete profileMatchesPage1Resolved[k]; }); Object.keys(profileMatchesPage1Inflight).forEach((k) => { delete profileMatchesPage1Inflight[k]; }); Object.keys(matchCountProbePage1Resolved).forEach((k) => { delete matchCountProbePage1Resolved[k]; }); Object.keys(matchCountProbePage1Inflight).forEach((k) => { delete matchCountProbePage1Inflight[k]; }); } /** * One GET for the newest MATCH_PAGE_SIZE matches; deduped across loadHofQuick + loadMatches (+ hybrid tail fetch). */ async function ensureProfileMatchesPage1Shared(playerId, lastOnline) { const id = String(playerId || "").trim(); if (!/^\d{1,20}$/.test(id)) return null; if (profileMatchesPage1Resolved[id]) return profileMatchesPage1Resolved[id]; if (profileMatchesPage1Inflight[id]) return profileMatchesPage1Inflight[id]; const ps = Number(CFG.MATCH_PAGE_SIZE || matchPageSize || 100); const inflight = api( `/accounts/${id}/matches?page[number]=1&page[size]=${ps}&unranked=true`, lastOnline ) .then((res) => { profileMatchesPage1Resolved[id] = res; delete profileMatchesPage1Inflight[id]; return res; }) .catch((err) => { delete profileMatchesPage1Inflight[id]; throw err; }); profileMatchesPage1Inflight[id] = inflight; return inflight; } function disconnectLazyMatchPrefetchObserver() { if (lazyMatchPrefetchObserver) { try { lazyMatchPrefetchObserver.disconnect(); } catch (_) {} lazyMatchPrefetchObserver = null; } } /** * Start fetching the next matches page only after the user scrolls until #loadMoreBtn is visible. * `separatorPrev`: optional { data, included } from the current tail — used when prefetch completes to refine last-day separator. */ function bindLazyMatchPrefetchNextPage(playerId, separatorPrev = null) { disconnectLazyMatchPrefetchObserver(); const id = String(playerId || "").trim(); if (!id || matchPage >= matchTotal) return; const arm = () => { const target = document.getElementById("loadMoreBtn") || document.getElementById("pagination"); if (!target) return; lazyMatchPrefetchObserver = new IntersectionObserver( (entries) => { if (!entries.some((e) => e.isIntersecting)) return; disconnectLazyMatchPrefetchObserver(); if (String(currentPlayerId || "") !== id) return; if (matchPage >= matchTotal) return; if (matchPrefetch && matchPrefetchId === id) return; matchPrefetchId = id; const nextNum = matchPage + 1; matchPrefetch = api( `/accounts/${id}/matches?page[number]=${nextNum}&page[size]=${matchPageSize}&unranked=true` ).catch(() => null); if (separatorPrev) { matchPrefetch.then((nextData) => { if (!nextData || String(matchPrefetchId || "") !== id) return; updateLastDaySeparator( separatorPrev.data || [], separatorPrev.included || [], nextData.data || [], nextData.included || [], id ); }); } }, { root: null, threshold: 0, rootMargin: "0px" } ); lazyMatchPrefetchObserver.observe(target); }; requestAnimationFrame(() => requestAnimationFrame(arm)); } async function loadMatches(id, page, lastOnline) { document.getElementById("matchesLoading").style.display = "block"; if (page === 1) { document.getElementById("matchesList").innerHTML = ""; resetMatchBannerAggregate(); } const mergeMatchesForHistory = (newMatches, cachedMatches) => { const map = new Map(); [...(newMatches || []), ...(cachedMatches || [])].forEach(m => { const mid = String(m?.id || ""); if (!mid || map.has(mid)) return; map.set(mid, m); }); return Array.from(map.values()).sort((a, b) => { const at = new Date(a?.attributes?.["created-at"] || 0).getTime(); const bt = new Date(b?.attributes?.["created-at"] || 0).getTime(); return bt - at; }); }; const mergeIncludedForHistory = (newIncluded, cachedIncluded) => { const map = new Map(); [...(newIncluded || []), ...(cachedIncluded || [])].forEach(i => { const type = String(i?.type || ""); const iid = String(i?.id || ""); if (!type || !iid) return; const key = `${type}:${iid}`; if (!map.has(key)) map.set(key, i); }); return Array.from(map.values()); }; const readFullStatsCacheForMatches = async (playerId) => { try { const r = await fetch("full-stats-cache-read.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: String(playerId) }) }); if (!r.ok) return { hasCache: false, matches: [], included: [] }; const json = await r.json().catch(() => null); if (!json?.ok || !json?.hasCache) return { hasCache: false, matches: [], included: [] }; return { hasCache: true, matches: Array.isArray(json.matches) ? json.matches : [], included: Array.isArray(json.included) ? json.included : [] }; } catch (_) { return { hasCache: false, matches: [], included: [] }; } }; const fetchRecentUntilOverlap = async (playerId, cachedIdSet, maxCachedId = 0) => { const maxPages = maxCachedId > 0 ? 20 : 4; // with fromMatchId we only pull genuinely new pages const rows = []; const inc = []; const fromMatchParam = maxCachedId > 0 ? `&fromMatchId=${maxCachedId + 1}` : ""; if (!fromMatchParam) { await ensureProfileMatchesPage1Shared(playerId, lastOnline).catch(() => null); } for (let p = 1; p <= maxPages; p++) { let d; if (p === 1 && !fromMatchParam && profileMatchesPage1Resolved[playerId]) { d = profileMatchesPage1Resolved[playerId]; } else { d = await api( `/accounts/${playerId}/matches?page[number]=${p}&page[size]=${matchPageSize}&unranked=true${fromMatchParam}`, p === 1 ? lastOnline : undefined ).catch(() => ({ data: [], included: [] })); } const dataRows = Array.isArray(d?.data) ? d.data : []; const dataInc = Array.isArray(d?.included) ? d.included : []; rows.push(...dataRows); inc.push(...dataInc); if (!dataRows.length) break; // When using fromMatchId the API only returns new matches — no overlap check needed. // Keep it as a safety net for the no-fromMatchId fallback path. if (!fromMatchParam && cachedIdSet.size > 0 && dataRows.some(m => cachedIdSet.has(String(m?.id || "")))) break; if (dataRows.length < matchPageSize) break; } return { rows, inc }; }; try { let data; const canUseHybrid = page === 1 && /^\d{1,20}$/.test(String(id || "")); if (canUseHybrid) { const cached = await readFullStatsCacheForMatches(id); if (cached.hasCache && Array.isArray(cached.matches) && cached.matches.length > 0) { const cachedIdSet = new Set(cached.matches.map(m => String(m?.id || "")).filter(Boolean)); const maxCachedId = cached.matches.reduce((mx, m) => Math.max(mx, Number(m?.id || 0)), 0); const recent = await fetchRecentUntilOverlap(id, cachedIdSet, maxCachedId); const mergedMatches = mergeMatchesForHistory(recent.rows, cached.matches); const mergedIncluded = mergeIncludedForHistory(recent.inc, cached.included); if (mergedMatches.length > 0) { matchHybridContext = { playerId: String(id), matches: mergedMatches, included: mergedIncluded }; matchPage = 1; matchTotal = Math.max(1, Math.ceil(mergedMatches.length / matchPageSize)); matchPrefetch = null; matchPrefetchId = null; document.getElementById("matchesLoading").style.display = "none"; renderMatches(mergedMatches.slice(0, matchPageSize), mergedIncluded, id, { bannerMatches: mergedMatches }); renderLoadMore(id); return; } } matchHybridContext = null; } // Daca avem date prefetchate pentru aceasta pagina, le folosim instant if (matchPrefetch && matchPrefetchId === id && matchPage === page) { data = await matchPrefetch; // poate fi deja rezolvat (instant) sau inca in zbor } else if (page === 1 && /^\d{1,20}$/.test(String(id || ""))) { try { data = await ensureProfileMatchesPage1Shared(id, lastOnline); } catch (_) { data = await api( `/accounts/${id}/matches?page[number]=${page}&page[size]=${matchPageSize}&unranked=true`, lastOnline ); } } else { data = await api( `/accounts/${id}/matches?page[number]=${page}&page[size]=${matchPageSize}&unranked=true`, lastOnline ); } matchPrefetch = null; matchPrefetchId = null; document.getElementById("matchesLoading").style.display = "none"; const lastLink = data.links?.last || ""; const lastMatch = lastLink.match(/page%5Bnumber%5D=(\d+)|page\[number\]=(\d+)/); matchTotal = lastMatch ? parseInt(lastMatch[1] || lastMatch[2]) : 1; mergeIntoMatchBannerAggregate(id, data.data || [], data.included || []); renderMatches(data.data || [], data.included || [], id, { bannerMatches: matchBannerAggregate.matches }); renderLoadMore(id); if (matchPage < matchTotal) { bindLazyMatchPrefetchNextPage(id, { data: data.data || [], included: data.included || [] }); } } catch (err) { document.getElementById("matchesLoading").textContent = t("matches_error"); } } async function loadMoreMatches(id) { if (matchHybridContext && String(matchHybridContext.playerId) === String(id)) { if (matchPage >= matchTotal) return; matchPage++; const start = (matchPage - 1) * matchPageSize; const end = start + matchPageSize; const chunk = (matchHybridContext.matches || []).slice(start, end); renderMatchesAppend(chunk, matchHybridContext.included || [], id); renderLoadMore(id); return; } if (matchPage >= matchTotal) return; matchPage++; const btn = document.getElementById("loadMoreBtn"); if (btn) { // Daca prefetch-ul e gata, butonul nu va arata loading const isReady = matchPrefetch && matchPrefetchId === id; if (!isReady) { btn.disabled = true; btn.textContent = t("matches_loading_btn"); } } document.getElementById("matchesLoading").style.display = "none"; try { let data; if (matchPrefetch && matchPrefetchId === id) { data = await matchPrefetch; matchPrefetch = null; matchPrefetchId = null; } else { data = await api( `/accounts/${id}/matches?page[number]=${matchPage}&page[size]=${matchPageSize}&unranked=true` ); } if (!data) throw new Error("No data"); mergeIntoMatchBannerAggregate(id, data.data || [], data.included || []); // Append meciuri noi la lista existenta const list = document.getElementById("matchesList"); const roundsMap = {}; (data.included || []).forEach(item => { if (item.type === "rounds") { const mid = String(item.attributes["match-id"]); if (!roundsMap[mid]) roundsMap[mid] = []; roundsMap[mid].push(item.attributes); } }); const tmp = document.createElement("div"); // Randam noile meciuri si le adaugam const playerId = id; renderMatchesAppend(data.data || [], data.included || [], playerId); renderLoadMore(id); if (matchPage < matchTotal) { bindLazyMatchPrefetchNextPage(id, { data: data.data || [], included: data.included || [] }); } } catch(err) { if (btn) { btn.disabled = false; btn.textContent = t("load_more"); } } } function renderMatches(matches, included, playerId, opts = {}) { const list = document.getElementById("matchesList"); if (!matches || matches.length === 0) { list.innerHTML = `
${t("matches_none")}
`; return; } const bannerMatches = opts.bannerMatches; // Rounds map (included pentru hybrid = merge complet => rounds pentru toate meciurile folosite la banner) const roundsMap = buildRoundsMapFromIncluded(included); // Separator de zi: totaluri din TOATA ziua din bannerMatches cand e disponibil (ex. cache hybrid sau API dupa prefetch) function daySeparatorHtml(iso, dayMatchesVisible, dayKeyStr) { const statsMatches = bannerMatches ? bannerMatches.filter(m => dayKey(m.attributes["created-at"]) === dayKeyStr) : dayMatchesVisible; const isoStats = statsMatches.length ? statsMatches[0].attributes["created-at"] : iso; return daySeparatorHtmlStandalone(isoStats, statsMatches, roundsMap, playerId); } // Grupeaza meciurile AFISATE pe zi (YYYY-MM-DD) const days = []; let currentDay = null; let currentMatches = []; matches.forEach(m => { const key = dayKey(m.attributes["created-at"]); if (key !== currentDay) { if (currentDay !== null) days.push({ key: currentDay, iso: currentMatches[0].attributes["created-at"], matches: currentMatches }); currentDay = key; currentMatches = [m]; } else { currentMatches.push(m); } }); if (currentMatches.length > 0) days.push({ key: currentDay, iso: currentMatches[0].attributes["created-at"], matches: currentMatches }); // Randeaza list.innerHTML = days.map(({ key, iso, matches: dayMatches }) => { const separator = daySeparatorHtml(iso, dayMatches, key).replace('
', `
`); const rows = dayMatches.map(m => renderMatchRow(m, roundsMap, playerId)).join(""); return separator + rows; }).join(""); } // Construieste HTML pentru separatorul de zi function daySeparatorHtmlStandalone(iso, dayMatches, roundsMap, playerId) { const d = new Date(iso); const dow = t("days_short")[d.getDay()]; const day = d.getDate(); const mon = d.toLocaleDateString("en-GB", { month: "short" }).toUpperCase(); const yr = d.getFullYear(); // Calculeaza ELO net al zilei const net = (() => { const first = dayMatches[0]; const last = dayMatches[dayMatches.length - 1]; const aFirst = first.attributes, aLast = last.attributes; const isHomeFirst = aFirst["home-user-id"] == playerId; const isHomeLast = aLast["home-user-id"] == playerId; const eloStart = isHomeLast ? aLast["home-elo"] : aLast["away-elo"]; const eloBeforeLast = isHomeFirst ? aFirst["home-elo"] : aFirst["away-elo"]; const eloChange = aFirst["elo-change"] ?? 0; const won = (aFirst.winner === 0 && isHomeFirst) || (aFirst.winner === 1 && !isHomeFirst); const eloEnd = eloBeforeLast + (won ? eloChange : -eloChange); if (eloStart == null || eloBeforeLast == null) return null; return Math.round(eloEnd - eloStart); })(); let eloHtml = ""; if (net !== null) { const sign = net > 0 ? "+" : ""; const valCls = net > 0 ? "day-elo-pos" : (net < 0 ? "day-elo-neg" : "day-elo-zero"); eloHtml = `
${sign}${net} ELO
`; } // Numarul de meciuri al zilei — doar meciuri finalizate (cineva a castigat 2 seturi) const playedMatches = dayMatches.filter(m => { const rounds = roundsMap[String(m.id)] || []; if (rounds.length === 0) return false; let homeSets = 0, awaySets = 0; rounds.forEach(r => { if (setIsComplete(r["home-score"], r["away-score"])) { if (r["home-score"] > r["away-score"]) homeSets++; else awaySets++; } }); return homeSets >= 2 || awaySets >= 2; }); const matchCount = playedMatches.length; // Durata zilei = suma duratelor estimate ale meciurilor (inclusiv ultimul set) let totalDurMs = 0; dayMatches.forEach(m => { const mid = String(m.id); const matchTs = new Date(m.attributes["created-at"]).getTime(); const rounds = roundsMap[mid] || []; if (rounds.length === 0) return; totalDurMs += estimateMatchDurMs(rounds, matchTs); }); let durationHtml = ""; if (totalDurMs > 0) { const durH = Math.floor(totalDurMs / 3600000); const durMin = Math.floor((totalDurMs % 3600000) / 60000); durationHtml = durH > 0 ? `${durH}h ${durMin}min` : `${durMin}min`; } const centerHtml = `
${matchCount} ${matchCount === 1 ? "match" : "matches"} ${durationHtml ? `·${durationHtml}` : ""}
`; return `
${dow} ${day} ${mon} ${yr}
${centerHtml} ${eloHtml}
`; } /** Touch phone in landscape — same idea as body.mobile-landscape / CSS breakpoints. */ function isTouchPhoneLandscape() { const touch = navigator.maxTouchPoints > 0 || "ontouchstart" in window; if (!touch) return false; if (window.innerWidth <= window.innerHeight) return false; return Math.min(window.innerWidth, window.innerHeight) < 600; } function renderMatchRow(m, roundsMap, playerId) { const a = m.attributes; const mid = String(m.id); const isHome = a["home-user-id"] == playerId; const won = (a.winner === 0 && isHome) || (a.winner === 1 && !isHome); const me = isHome ? (a["home-team"] || [])[0] : (a["away-team"] || [])[0]; const opp = isHome ? (a["away-team"] || [])[0] : (a["home-team"] || [])[0]; if (!me || !opp) return ""; const resultClass_base = won ? "win" : "loss"; const rounds = roundsMap[mid] || []; let homeSetsWon = 0, awaySetsWon = 0; rounds.forEach(r => { if (setIsComplete(r["home-score"], r["away-score"])) { if (r["home-score"] > r["away-score"]) homeSetsWon++; else awaySetsWon++; } }); const isAborted = homeSetsWon < 2 && awaySetsWon < 2; const resultClass = isAborted ? "aborted" : resultClass_base; // ELO impact — calculat inainte de arrow ca sa controleze iconita // Aliniat cu regula din popup H2H: // tratam meciul ca ranked daca API il marcheaza ranked // SAU daca exista impact ELO nenul. const isRanked_arrow = a["ranked"] !== false || (a["elo-change"] != null && a["elo-change"] !== 0); const eloChange_arrow = a["elo-change"] ?? null; const totalPts_arrow = (roundsMap[mid] || []).reduce((s, r) => s + (r["home-score"] || 0) + (r["away-score"] || 0), 0); const isForfeited_arrow = (a["ranked"] === false && a["elo-change"] != null && totalPts_arrow === 0) || (eloChange_arrow === 0 && totalPts_arrow === 0); const noEloImpact = isForfeited_arrow || !isRanked_arrow || eloChange_arrow === 0; const arrow = (isAborted || noEloImpact) ? `` : won ? `` : ``; const scoreStr = rounds.map(r => { const ms = isHome ? r["home-score"] : r["away-score"]; const os = isHome ? r["away-score"] : r["home-score"]; return `${ms}-${os}`; }).join(" / "); // Scor general la seturi (ex: 2-1) pentru mobile let mySets = 0, oppSets = 0; rounds.forEach(r => { if (setIsComplete(r["home-score"], r["away-score"])) { const ms = isHome ? r["home-score"] : r["away-score"]; const os = isHome ? r["away-score"] : r["home-score"]; if (ms > os) mySets++; else oppSets++; } }); const scoreSummary = rounds.length > 0 ? `${mySets}-${oppSets}` : "—"; const myEloAtMatch = isHome ? a["home-elo"] : a["away-elo"]; const oppEloAtMatch = isHome ? a["away-elo"] : a["home-elo"]; // Nume definite devreme — necesare si pentru tooltip-ul diff-ului de ELO const oppName = opp.UserName || opp.username || "?"; const profileName = currentPlayer?.attributes?.["user-name"] || "—"; let eloDiffHtml = ""; if (myEloAtMatch != null && oppEloAtMatch != null) { const diff = Math.round(oppEloAtMatch - myEloAtMatch); const label = diff > 0 ? `>${diff}` : diff < 0 ? `<${Math.abs(diff)}` : "="; let cls; if (won && diff > 0) cls = "elo-diff-upset-win"; else if (!won && diff < 0) cls = "elo-diff-upset-loss"; else cls = "elo-diff-normal"; const diffTip = diff === 0 ? t("tip_elodiff_equal") : diff > 0 ? t("tip_elodiff_ahead", `${escHtml(oppName)}`, `${escHtml(profileName)}`, diff) : t("tip_elodiff_ahead", `${escHtml(profileName)}`, `${escHtml(oppName)}`, Math.abs(diff)); eloDiffHtml = `${label}`; } const isRanked = isRanked_arrow; const eloChange = eloChange_arrow; const totalPts = totalPts_arrow; const isForfeited = isForfeited_arrow; const isWaived = isRanked && isWaivedByRoundsOrRaw(m, roundsMap); const waivedLeftName = won ? oppName : profileName; const waivedTipHtml = t("tip_waived", `${escHtml(waivedLeftName)}`); const isMobileRender = window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX; const oppId = opp.id; const eloTapAttrs = oppId ? ` data-opp-id="${escHtml(oppId)}" data-opp-name="${escHtml(oppName)}" onclick="openMatchupFromEloCell(event, this)"` : ""; let eloHtml = ""; if (isForfeited) { if (isWaived) { // WAIVED are deja status explicit in coloana de scor; aici afisam impact neutru "0" // colorat contextual (verde daca opponent a abandonat, rosu daca jucatorul curent a abandonat). const waivedCls = won ? "pos" : "neg"; eloHtml = `
0
`; } else if (!isRanked) { // Pentru meciurile unranked 0-0 fara puncte, coloana ramane in regim UNRANKED. // Cine a parasit jocul este deja explicat in ultima coloana. eloHtml = `
CASUAL
`; } else { // Fallback pentru cazuri forfeited care nu intra pe regula WAIVED. const forfCls = won ? "match-forfeited-opp" : "match-forfeited-me"; const forfLabelKey = isMobileRender ? (won ? "label_forfeit_short" : "label_quit_short") : "label_forfeited"; const forfLabel = `${t(forfLabelKey)}`; eloHtml = `
${forfLabel}
`; } } else if (!isRanked) { eloHtml = `
CASUAL
`; } else if (eloChange === 0) { eloHtml = `
NOT RANKED
`; } else if (eloChange !== null) { const signed = won ? `+${eloChange}` : `-${eloChange}`; const cls = won ? "pos" : "neg"; eloHtml = `
${signed}
`; } else { eloHtml = `
`; } const oppCountry = opp["country-code"] || null; const oppFlagCode = (oppCountry && oppCountry !== "none" && oppCountry !== "Earth" && oppCountry !== "null") ? oppCountry.toLowerCase() : "un"; const oppFlag = ``; // Ora meciului + durata calculata din seturile disponibile const matchStart = new Date(a["created-at"]); const hh = String(matchStart.getHours()).padStart(2, "0"); const mm = String(matchStart.getMinutes()).padStart(2, "0"); let timeStr = `${hh}:${mm}`; // Durata: estimam proportional din punctele jucate per set const allRounds = roundsMap[mid] || []; if (allRounds.length > 0) { const durMs = estimateMatchDurMs(allRounds, matchStart.getTime()); const durMin = Math.floor(durMs / 60000); const durSec = Math.floor((durMs % 60000) / 1000); if (durMin > 0 || durSec > 0) { timeStr += ` ${durMin}'${String(durSec).padStart(2,"0")}"`; } } let abortedHtml = `
`; if (isAborted) { const myName = me.UserName || me.username || "?"; const abortedByMe = !won; const abortedName = abortedByMe ? escHtml(myName) : escHtml(oppName); const cls = abortedByMe ? "aborted-me" : "aborted-opp"; const abortedTail = isTouchPhoneLandscape() ? " LEFT" : " LEFT THE GAME"; abortedHtml = `
${abortedName}${abortedTail}
`; } const safeOppNameJs = String(oppName || "?").replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const matchIdHtml = ``; const abortedMidGame = isAborted && !isForfeited; const abortedByName = won ? oppName : profileName; const scoreExtraName = abortedMidGame ? String(abortedByName || "") : ""; const scoreExtra = scoreExtraName ? t("tip_left_game_simple", `${escHtml(scoreExtraName)}`) : ""; let abortedPillCls = "unranked"; if (abortedMidGame && isRanked && eloChange !== null && eloChange !== 0) { abortedPillCls = won ? "ranked-win" : "ranked-loss"; } const scoreSummaryMobile = (isMobileRender && abortedMidGame) ? `${scoreSummary}` : scoreSummary; return `
0 ? ' data-completed="1"' : ''}>
${arrow}
${oppId ? `${oppFlag}${escHtml(oppName)}` : `${oppFlag}${escHtml(oppName)}` }${eloDiffHtml ? `${eloDiffHtml}` : ""}
${oppEloAtMatch ? `${Math.round(oppEloAtMatch)}` : ""}
${eloDiffHtml}
${isWaived ? `WAIVED` : `${scoreSummary}${scoreStr ? ` (${scoreStr})` : ""}`}
${scoreExtra}` : ""}"` : "")}>${isWaived ? `WAIVED` : scoreSummaryMobile}
${eloHtml}
${timeStr}
${abortedHtml} ${matchIdHtml}
`; } // Adauga meciuri noi la lista existenta (pentru load more) // Actualizeaza silentios separatorul ultimei zile daca ziua continua pe pagina urmatoare function updateLastDaySeparator(curMatches, curIncluded, nextMatches, nextIncluded, playerId) { if (!curMatches.length || !nextMatches.length) return; const lastMatch = curMatches[curMatches.length - 1]; const lastDayKey = dayKey(lastMatch.attributes["created-at"]); const firstNext = nextMatches[0]; const firstDayKey = dayKey(firstNext.attributes["created-at"]); if (lastDayKey !== firstDayKey) return; mergeIntoMatchBannerAggregate(playerId, nextMatches, nextIncluded); const list = document.getElementById("matchesList"); const allSeps = list.querySelectorAll(".day-separator"); const sepEl = allSeps.length > 0 ? allSeps[allSeps.length - 1] : null; if (!sepEl || sepEl.dataset.day !== lastDayKey) return; if (!matchBannerAggregate || matchBannerAggregate.playerId !== String(playerId)) return; const fullDay = matchBannerAggregate.matches.filter(m => dayKey(m.attributes["created-at"]) === lastDayKey); if (!fullDay.length) return; const roundsMap = buildRoundsMapFromIncluded(matchBannerAggregate.included); refreshDaySeparatorFromMatches(sepEl, fullDay[0].attributes["created-at"], fullDay, roundsMap, playerId); } function renderMatchesAppend(matches, included, playerId) { const list = document.getElementById("matchesList"); if (!matches || matches.length === 0) return; const roundsMap = {}; (included || []).forEach(item => { if (item.type === "rounds") { const mid = String(item.attributes["match-id"]); if (!roundsMap[mid]) roundsMap[mid] = []; roundsMap[mid].push(item.attributes); } }); // Detectam ultima zi deja afisata const allSeps = list.querySelectorAll(".day-separator"); const lastSepEl = allSeps.length > 0 ? allSeps[allSeps.length - 1] : null; const lastDayKey = lastSepEl?.dataset.day || null; // Grupeaza meciurile noi pe zile const days = []; let currentDay = null, currentMatches = []; matches.forEach(m => { const key = dayKey(m.attributes["created-at"]); if (key !== currentDay) { if (currentDay !== null) days.push({ key: currentDay, iso: currentMatches[0].attributes["created-at"], matches: currentMatches }); currentDay = key; currentMatches = [m]; } else { currentMatches.push(m); } }); if (currentMatches.length > 0) days.push({ key: currentDay, iso: currentMatches[0].attributes["created-at"], matches: currentMatches }); const frag = document.createDocumentFragment(); days.forEach(({ key, iso, matches: dayMatches }, idx) => { const isContinuation = (idx === 0 && key === lastDayKey); if (isContinuation) { // Ziua continua din pagina anterioara: // 1. Adaugam meciurile fara separator nou // 2. Actualizam separatorul existent cu noile totaluri dayMatches.forEach(m => { const rowDiv = document.createElement("div"); rowDiv.innerHTML = renderMatchRow(m, roundsMap, playerId); if (rowDiv.firstElementChild) frag.appendChild(rowDiv.firstElementChild); }); // Actualizam separatorul existent dupa ce fragmentul e in DOM // (folosim setTimeout ca DOM-ul sa fie actualizat) const sepToUpdate = lastSepEl; const dk = lastDayKey; const pid = playerId; setTimeout(() => { let fullDay = []; let incSrc = included; if (matchHybridContext && String(matchHybridContext.playerId) === String(pid)) { fullDay = matchHybridContext.matches.filter(m => dayKey(m.attributes["created-at"]) === dk); incSrc = matchHybridContext.included || []; } else if (matchBannerAggregate && matchBannerAggregate.playerId === String(pid)) { fullDay = matchBannerAggregate.matches.filter(m => dayKey(m.attributes["created-at"]) === dk); incSrc = matchBannerAggregate.included; } else if (window.__combinedRecentBanner && String(window.__combinedRecentBanner.playerId) === String(pid)) { fullDay = window.__combinedRecentBanner.matches.filter(m => dayKey(m.attributes["created-at"]) === dk); incSrc = window.__combinedRecentBanner.included || []; } if (!fullDay.length) return; const rm = buildRoundsMapFromIncluded(incSrc); refreshDaySeparatorFromMatches(sepToUpdate, fullDay[0].attributes["created-at"], fullDay, rm, pid); }, 0); } else { // Zi noua — adaugam separator + meciuri (banner din lista completa daca e disponibila) let statsForBanner = dayMatches; let incForBanner = included; if (matchHybridContext && String(matchHybridContext.playerId) === String(playerId)) { statsForBanner = matchHybridContext.matches.filter(m => dayKey(m.attributes["created-at"]) === key); incForBanner = matchHybridContext.included || []; } else if (matchBannerAggregate && matchBannerAggregate.playerId === String(playerId)) { statsForBanner = matchBannerAggregate.matches.filter(m => dayKey(m.attributes["created-at"]) === key); incForBanner = matchBannerAggregate.included; } else if (window.__combinedRecentBanner && String(window.__combinedRecentBanner.playerId) === String(playerId)) { statsForBanner = window.__combinedRecentBanner.matches.filter(m => dayKey(m.attributes["created-at"]) === key); incForBanner = window.__combinedRecentBanner.included || []; } const isoBanner = statsForBanner.length ? statsForBanner[0].attributes["created-at"] : iso; const rmBanner = buildRoundsMapFromIncluded(incForBanner); const sepDiv = document.createElement("div"); sepDiv.innerHTML = daySeparatorHtmlStandalone(isoBanner, statsForBanner, rmBanner, playerId); const sepEl = sepDiv.firstElementChild; sepEl.dataset.day = key; frag.appendChild(sepEl); dayMatches.forEach(m => { const rowDiv = document.createElement("div"); rowDiv.innerHTML = renderMatchRow(m, roundsMap, playerId); if (rowDiv.firstElementChild) frag.appendChild(rowDiv.firstElementChild); }); } }); list.appendChild(frag); } function renderLoadMore(id) { const pg = document.getElementById("pagination"); if (matchPage >= matchTotal) { pg.innerHTML = `
${matchPage * matchPageSize >= matchTotal * matchPageSize ? t("matches_all_loaded") : ""}
`; return; } const remaining = (matchTotal - matchPage) * matchPageSize; pg.innerHTML = ``; } // ── HALL OF FAME ────────────────────────────────────────── async function loadHofQuick(id, lastOnline) { try { // O singura pagina (MATCH_PAGE_SIZE) partajata cu Recent Matches + probe page[size]=1 (partajata cu First active). const headPs = Number(CFG.MATCH_PAGE_SIZE || matchPageSize || 100); const [page1Resp, countProbe] = await Promise.all([ ensureProfileMatchesPage1Shared(id, lastOnline), ensureMatchCountProbePage1(id).catch(() => null) ]); const allMatches = Array.isArray(page1Resp?.data) ? page1Resp.data : []; const included = Array.isArray(page1Resp?.included) ? page1Resp.included : []; // Snapshot pentru popup-ul H2H (quick = ultimele headPs meciuri) if (currentPlayerId == id) { matchupMatchesSnapshot = allMatches.slice(); matchupIncludedSnapshot = included.slice(); matchupSnapshotPlayerId = id; } let exactTotal = exactMatchTotalFromSize1Probe(countProbe); if (!exactTotal) { const countLink = page1Resp?.links?.last || ""; const countMatch = countLink.match(/page%5Bnumber%5D=(\d+)|page\[number\]=(\d+)/); const totalPages = countMatch ? parseInt(countMatch[1] || countMatch[2], 10) : 0; const metaTot = matchCollectionTotalFromMeta(page1Resp); const p1Len = Array.isArray(page1Resp?.data) ? page1Resp.data.length : 0; let lastLen = 0; if (!(metaTot > 0) && totalPages > 1) { const lastResp = await apiViaProxy( `/accounts/${id}/matches?page[number]=${totalPages}&page[size]=${headPs}&unranked=true`, 20 ).catch(() => null); lastLen = Array.isArray(lastResp?.data) ? lastResp.data.length : 0; } exactTotal = metaTot > 0 || totalPages > 0 ? exactMatchTotalFromPages(metaTot, totalPages, headPs, p1Len, lastLen) : allMatches.length; } // Afisam totalul exact in nota de sub statTotal if (exactTotal && currentPlayerId == id) { const note = document.getElementById("statTotalNote"); if (note) note.innerHTML = `${exactTotal.toLocaleString()} ${t("label_incl_unranked")}`; // Corectam matchTotal cu valoarea exacta — links.last din page[size]=100 // poate da un total mai mic daca ultima pagina e partiala const correctPages = Math.ceil(exactTotal / matchPageSize); if (correctPages > matchTotal) { matchTotal = correctPages; renderLoadMore(id); // reafisam butonul daca era ascuns prematur } } const stats = computeHofStats(allMatches, included, id); // Afisam doar daca profilul curent e tot cel pentru care s-a calculat if (currentPlayerId == id) { renderHof(stats, "hof_scope_quick"); updateCurrentFormCard(allMatches, id); // Ranked all-time din profil / total exact => procent corect const rankedAllTime = (currentPlayer?.attributes?.wins ?? 0) + (currentPlayer?.attributes?.losses ?? 0); updateRankedPct(rankedAllTime, exactTotal); if (exactTotal > CFG.HOF_FULL_THRESHOLD) { document.getElementById("hofFullBtn").dataset.total = exactTotal; document.getElementById("hofActions").style.display = "block"; } void refreshUniqueOppCard(id); scheduleProfileEloHistory(id); } } catch(e) { const fallback = await readFullStatsCacheForProfile(id); if (currentPlayerId == id && fallback?.hasCache && Array.isArray(fallback.matches) && fallback.matches.length) { const quickCount = Math.max(1, Number(CFG.MATCH_PAGE_SIZE || matchPageSize || 100)); const allMatches = fallback.matches.slice(0, quickCount); const included = Array.isArray(fallback.included) ? fallback.included : []; const exactTotal = Number(fallback.matchCount || fallback.matches.length || allMatches.length); matchupMatchesSnapshot = allMatches.slice(); matchupIncludedSnapshot = included.slice(); matchupSnapshotPlayerId = id; const stats = computeHofStats(allMatches, included, id); renderHof(stats, "hof_scope_quick"); updateCurrentFormCard(allMatches, id); const note = document.getElementById("statTotalNote"); if (note && exactTotal > 0) { note.innerHTML = `${exactTotal.toLocaleString()} ${t("label_incl_unranked")}`; } const rankedAllTime = (currentPlayer?.attributes?.wins ?? 0) + (currentPlayer?.attributes?.losses ?? 0); if (exactTotal > 0) updateRankedPct(rankedAllTime, exactTotal); if (exactTotal > CFG.HOF_FULL_THRESHOLD) { document.getElementById("hofFullBtn").dataset.total = exactTotal; document.getElementById("hofActions").style.display = "block"; } void refreshUniqueOppCard(id); scheduleProfileEloHistory(id); return; } if (currentPlayerId == id) { document.getElementById("hofGrid").innerHTML = `
${t("hof_loading")} — error
`; } } } async function loadFullStats() { const btn = document.getElementById("hofFullBtn"); const progress = document.getElementById("hofProgress"); const progressBar= document.getElementById("hofProgressBar"); const progressLbl= document.getElementById("hofProgressLabel"); // dataset.total contine acum totalul EXACT de meciuri (din request-ul cu size=1) const exactTotal = parseInt(btn.dataset.total) || CFG.HOF_FULL_TOTAL_FALLBACK; const id = currentPlayerId; const useServerStorage = Number(CFG.FULL_STATS_STORAGE ?? 1) === 1; /** Tear down chart state, then reload ELO on the next task (full-stats / cache visible). Runs even when the ELO panel is collapsed so hero MAX updates. */ const reinitEloChartIfOpen = () => { delete eloHistoryTierByPlayer[id]; delete eloMarkerMatchesByPlayer[id]; delete eloMarkerIncludedByPlayer[id]; delete eloMarkerLoadPromiseByPlayer[id]; if (eloChart) { try { eloChart.destroy(); } catch (_) {} eloChart = null; } eloData = null; eloTitlePointCount = null; updateEloDynamicTitle(); eloLoadSeq++; const pid = id; queueMicrotask(() => { if (String(currentPlayerId || "") !== String(pid)) return; void loadEloHistory(pid); }); }; // Verificam cache-ul HOF full const cached = hofCacheGet(id); if (cached) { renderHof(cached, "hof_scope_full"); const rankedAllTime = officialRankedMatchTotalFromProfile() || Number(cached.totalRanked || 0); // Use the exact all-matches denominator (from size=1 probe), not cache/list lengths. // Cache totals can be partial/stale and can incorrectly push Ranked% to 100. updateRankedPct(rankedAllTime, exactTotal); void refreshUniqueOppCard(id); btn.style.display = "none"; document.getElementById("hofActions").style.display = "none"; reinitEloChartIfOpen(); return; } // Calculam numarul de pagini la page[size]=100 din totalul exact const totalPagesLarge = Math.ceil(exactTotal / CFG.MATCH_PAGE_SIZE) || 1; const totalNum = exactTotal; btn.style.display = "none"; progress.style.display = "flex"; progressBar.style.width = "0%"; progressBar.style.transition = "none"; // Force reflow so first animated step starts from 0%. void progressBar.offsetWidth; progressBar.style.transition = ""; progressLbl.textContent = t("hof_retrieving", totalNum); const allMatches = [], allIncluded = []; const BATCH = CFG.HOF_FULL_BATCH; const progressTrack = progressBar?.parentElement || null; let funnyTimer = null; const stopFunny = () => { if (funnyTimer) { clearInterval(funnyTimer); funnyTimer = null; } }; const startFunny = () => { if (progressTrack) progressTrack.style.display = "none"; const msgs = SEARCH_LOADING_MSGS[currentLang] || SEARCH_LOADING_MSGS.en; let msgIdx = Math.floor(Math.random() * Math.max(1, msgs.length)); const funnyTick = () => { if (!Array.isArray(msgs) || msgs.length === 0) { progressLbl.textContent = randomSearchMsg(); return; } progressLbl.textContent = msgs[msgIdx]; msgIdx = (msgIdx + 1) % msgs.length; }; funnyTick(); funnyTimer = setInterval(funnyTick, 1000); }; const showProgressBar = () => { stopFunny(); if (progressTrack) progressTrack.style.display = ""; }; const runClientFlow = async () => { showProgressBar(); let pagesCompletedCount = 0; const bumpProgress = () => { if (currentPlayerId !== id) return; const pct = Math.min(100, Math.round((pagesCompletedCount / totalPagesLarge) * 100)); if (progressBar) progressBar.style.width = `${pct}%`; if (progressLbl) progressLbl.textContent = `${t("hof_retrieving", totalNum)} ${pct}%`; }; for (let start = 1; start <= totalPagesLarge; start += BATCH) { if (currentPlayerId !== id) return; const end = Math.min(start + BATCH - 1, totalPagesLarge); const n = end - start + 1; const slots = new Array(n); await Promise.all( Array.from({ length: n }, (_, i) => { const pageNum = start + i; return apiViaProxy( `/accounts/${id}/matches?page[number]=${pageNum}&page[size]=${CFG.MATCH_PAGE_SIZE}&unranked=true`, 20 ) .catch(() => ({ data: [], included: [] })) .then((d) => { slots[i] = d; pagesCompletedCount++; bumpProgress(); return d; }); }) ); for (let i = 0; i < n; i++) { const d = slots[i] || { data: [], included: [] }; allMatches.push(...(d.data || [])); allIncluded.push(...(d.included || [])); } } }; if (useServerStorage) { let hasServerCache = false; try { const hasResp = await fetch("full-stats-cache-has.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: String(id) }) }); if (hasResp.ok) { const hasJson = await hasResp.json().catch(() => null); hasServerCache = !!hasJson?.ok && !!hasJson?.hasCache; } } catch (_) {} if (hasServerCache) { // Cache exists: no progress bar, use funny rotating messages while server prepares data. startFunny(); try { const resp = await fetch("full-stats-cache.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: String(id), exactTotal: totalNum, playerName: String(currentPlayer?.attributes?.["user-name"] || "") }) }); if (!resp.ok) throw new Error("full-stats-cache failed"); const raw = await resp.text(); const json = JSON.parse(raw); if (!json?.ok || !Array.isArray(json.matches) || !Array.isArray(json.included)) { throw new Error("invalid full-stats-cache response"); } allMatches.push(...json.matches); allIncluded.push(...json.included); console.info("[full-stats-cache] served from server cache flow", { playerId: id, cacheFile: json.cacheFile, pagesFetched: json.pagesFetched, newMatches: json.newMatches, cacheWriteOk: json.cacheWriteOk }); } catch (e) { console.warn("[full-stats-cache] cache-first failed, fallback to client flow", e); await runClientFlow(); } finally { stopFunny(); } } else { // No cache yet: start server JSON build in parallel; do not await — UI uses client-fetched rows immediately. void fetch("full-stats-cache.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ playerId: String(id), exactTotal: totalNum, playerName: String(currentPlayer?.attributes?.["user-name"] || "") }) }) .then(async (r) => { if (!r.ok) throw new Error(`full-stats-cache warm HTTP ${r.status}`); await r.json().catch(() => null); }) .catch((e) => { console.warn("[full-stats-cache] background warm failed", e); }); await runClientFlow(); } } else { console.info("[full-stats-cache] skipped (FULL_STATS_STORAGE=0)"); await runClientFlow(); } if (currentPlayerId !== id) return; // Paginile s-au terminat — ascundem bara înainte de computeHofStats (buclă mare, sincronă). // Altfel UI rămâne înghețat pe „… 100%” multe secunde până termină agregarea pe client. stopFunny(); progress.style.display = "none"; await new Promise((resolve) => requestAnimationFrame(() => resolve())); const stats = computeHofStats(allMatches, allIncluded, id); // Snapshot complet pentru popup-ul H2H (all-time) matchupMatchesSnapshot = allMatches.slice(); matchupIncludedSnapshot = allIncluded.slice(); matchupSnapshotPlayerId = id; // Salvam rezultatul calculat in cache (nu datele brute) hofCacheSet(id, stats); renderHof(stats, "hof_scope_full"); updateCurrentFormCard(allMatches, id); const rankedAllTime = officialRankedMatchTotalFromProfile() || Number(stats.totalRanked || 0); // Keep Ranked% consistent with the exact total displayed under Ranked Matches. updateRankedPct(rankedAllTime, totalNum); void refreshUniqueOppCard(id, allMatches, allIncluded); document.getElementById("hofActions").style.display = "none"; stashClientFullStatsFetchBundle(id, allMatches, allIncluded); reinitEloChartIfOpen(); } function computeHofStats(matches, included, playerId) { const roundsMap = {}; const userCountryById = {}; const userLastOnlineById = {}; (included || []).forEach(item => { if (item.type === "rounds") { const mid = String(item.attributes["match-id"]); if (!roundsMap[mid]) roundsMap[mid] = []; roundsMap[mid].push(item.attributes); return; } const iid = String(item?.id || ""); const attrs = item?.attributes || {}; const cc = attrs["country-code"] || attrs["countryCode"] || null; if (iid && cc && cc !== "none" && cc !== "Earth" && cc !== "null") { userCountryById[iid] = cc; } const lo = attrs["last-online"] || attrs["lastOnline"] || null; if (iid && lo && !userLastOnlineById[iid]) { userLastOnlineById[iid] = lo; } }); let bestUpsetWin = null, worstUpsetLoss = null; const signatureVictoryTop = []; const underdogStingTop = []; const biggestComebackTop = []; const longestMatchTop = []; const fastFuriousTop = []; let mostActiveDayTop = []; let bestDayEverTop = []; let dieHardTop = []; let favVictimTop = []; let longestRivalryTop = []; let curWin = 0, maxWin = 0, winStreakStart = null, winStreakEnd = null, curWinEndDate = null; let curLoss = 0, maxLoss = 0, lossStreakStart = null, lossStreakEnd = null, curLossEndDate = null; let winStreakEndElo = null, curWinEndElo = null; let lossStreakEndElo = null, curLossEndElo = null; const oppCount = {}; // { id: { name, wins, losses } } — ranked only (Nemesis/Victim/Rivalry) const oppAll = {}; // { id: { name, total, country } } — toate meciurile (Most Played) const uniqueOppSet = new Set(); // all encountered opponents (any match type/status) let bestComeback = null; // { oppName, oppId, date, sets[] } — ranked only let longestMatch = null; // { oppName, oppId, date, durMs } let fastFurious = null; // shortest won match by points played let primeTime = null; // average session start/end let bestDayElo = null; // { date, net } let mostActiveDay = null; // { date, count, durMs } const localOpp = {}; // { id: { name, country, lastElo, matches, ranked, wins, losses } } — same-country opponents const myCountryRaw = currentPlayer?.attributes?.["country-code"] || null; const myCountry = (myCountryRaw && myCountryRaw !== "none" && myCountryRaw !== "Earth" && myCountryRaw !== "null") ? String(myCountryRaw).toLowerCase() : null; // Statistici abandon let totalRanked = 0; /** Meciuri cu `ranked !== false` — acelasi criteriu ca la Biggest Comeback / label "ranked" in HOF. */ let rankedHoFMatchCount = 0; let abandonMeImpact = 0; // abandon eu, cu puncte jucate let abandonMeImpactElo = 0; // ELO pierdut prin aceste abandoane let abandonMeZero = 0; // abandon eu, 0-0 let abandonOppImpact = 0; // abandon adversar, cu puncte jucate let abandonOppImpactElo= 0; // ELO castigat prin abandonurile adversarilor // Pentru Best Day Ever — grupam pe zile const dayEloMap = {}; // { "YYYY-MM-DD": { firstElo, lastElo, lastEloChange, lastWon, lastMatchTs } } const dayMatchMap = {}; // { "YYYY-MM-DD": { count, totalDurMs, firstDate } } const sessionCandidates = []; const addDayAccount = (bucket, name, country) => { const accountName = String(name || "").trim(); const accountCountry = String(country || "").trim(); if (!bucket || !accountName) return; if (!Array.isArray(bucket.accounts)) bucket.accounts = []; if (!bucket.accounts.some(a => a.name === accountName)) { bucket.accounts.push({ name: accountName, country: accountCountry }); } }; matches.forEach(m => { const a = m.attributes; const mid = String(m.id); const homeTeam = Array.isArray(a["home-team"]) ? a["home-team"] : []; const awayTeam = Array.isArray(a["away-team"]) ? a["away-team"] : []; const pid = String(playerId || "").trim(); homeTeam.forEach(p => { const oid = String(p?.id || "").trim(); if (oid && oid !== pid) uniqueOppSet.add(oid); }); awayTeam.forEach(p => { const oid = String(p?.id || "").trim(); if (oid && oid !== pid) uniqueOppSet.add(oid); }); const homeUid = String(a["home-user-id"] || "").trim(); const awayUid = String(a["away-user-id"] || "").trim(); if (homeUid === pid && awayUid && awayUid !== pid) uniqueOppSet.add(awayUid); if (awayUid === pid && homeUid && homeUid !== pid) uniqueOppSet.add(homeUid); const isHome = a["home-user-id"] == playerId; const won = (a.winner === 0 && isHome) || (a.winner === 1 && !isHome); const isRanked = a["ranked"] !== false; const isRankedFull = a["ranked"] !== false || (a["elo-change"] != null && a["elo-change"] !== 0); if (isRanked) rankedHoFMatchCount++; const myElo = isHome ? a["home-elo"] : a["away-elo"]; const oppElo = isHome ? a["away-elo"] : a["home-elo"]; const diff = Math.round((oppElo || 0) - (myElo || 0)); const opp = isHome ? (a["away-team"] || [])[0] : (a["home-team"] || [])[0]; const oppName = opp?.UserName || "?"; const oppId = opp?.id; const oppCountryResolved = (() => { const direct = opp?.["country-code"]; if (direct && direct !== "none" && direct !== "Earth" && direct !== "null") return direct; const mapVal = userCountryById[String(oppId || "")]; return mapVal || null; })(); const ownerName = String(a["ett-real-owner-name"] || "").trim(); const ownerCountry = String(a["ett-real-owner-country"] || "").trim(); const ownerInfo = ownerName ? { ownerName, ownerCountry } : {}; const date = a["created-at"]; const dk = date ? dayKey(date) : null; const matchRounds = roundsMap[mid] || []; const rawHomeScore = Number(a["home-score"] || 0); const rawAwayScore = Number(a["away-score"] || 0); const totalPts = matchRounds.reduce((s, r) => s + (Number(r["home-score"] || 0) + Number(r["away-score"] || 0)), 0); const eloChangeNum = Number(a["elo-change"] ?? NaN); // Forfeited = 0-0 fara impact ELO. Daca rounds lipsesc, folosim fallback pe scorul brut. const isForfeitedZero = ((matchRounds.length > 0 && totalPts === 0) || (matchRounds.length === 0 && rawHomeScore === 0 && rawAwayScore === 0)) && Number.isFinite(eloChangeNum) && eloChangeNum === 0; let completedHomeSets = 0, completedAwaySets = 0; matchRounds.forEach(r => { if (!setIsComplete(r["home-score"], r["away-score"])) return; if (r["home-score"] > r["away-score"]) completedHomeSets++; else completedAwaySets++; }); // Pentru Most Played Opponent numaram doar meciuri terminate (nu forfeited/abandoned). // Fallback pe scorul brut in cazurile vechi unde rounds poate lipsi. const completedByRounds = completedHomeSets >= 2 || completedAwaySets >= 2; const completedByRawScore = (rawHomeScore >= 2 || rawAwayScore >= 2) && (rawHomeScore > 0 || rawAwayScore > 0); const isCompletedMatch = completedByRounds || completedByRawScore; // Retinem data celui mai recent meci ranked (meciurile sunt newest-first) if (!lastRankedMatchDate && isRanked && date) lastRankedMatchDate = date; // Construim map eloAfter -> date: data la care a fost ATINS fiecare nivel ELO // myElo = ELO inainte de meci; eloAfter = ELO dupa meci (rezultatul meciului) if (isRanked && date && myElo != null && a["elo-change"] != null) { const eloAfter = Math.round(myElo + (won ? a["elo-change"] : -a["elo-change"])); // First-write-wins: meciurile sunt newest-first → prima scriere = data cea mai recenta if (!eloAchievedDateMap[eloAfter]) eloAchievedDateMap[eloAfter] = date; } // Opponent stats pentru Nemesis / Favorite Victim / Rivalry — DOAR ranked if (oppId && isRanked && !isForfeitedZero) { if (!oppCount[oppId]) oppCount[oppId] = { name: oppName, wins: 0, losses: 0, country: null, lastElo: null }; const oppC = oppCountryResolved; if (oppC && oppC !== "none" && oppC !== "Earth" && oppC !== "null") { oppCount[oppId].country = oppC; } if (oppCount[oppId].lastElo === null && oppElo != null) oppCount[oppId].lastElo = Math.round(oppElo); if (won) oppCount[oppId].wins++; else oppCount[oppId].losses++; } // Opponent stats pentru Most Played Opponent — doar meciuri finalizate (ranked + unranked) if (oppId && isCompletedMatch) { if (!oppAll[oppId]) oppAll[oppId] = { name: oppName, total: 0, ranked: 0, country: null, lastElo: null }; const oppC = oppCountryResolved; if (oppC && oppC !== "none" && oppC !== "Earth" && oppC !== "null") { oppAll[oppId].country = oppC; } if (oppAll[oppId].lastElo === null && oppElo != null) oppAll[oppId].lastElo = Math.round(oppElo); oppAll[oppId].total++; if (isRankedFull) oppAll[oppId].ranked++; } // Local Rivals — same-country opponents, based on completed matches. if (myCountry && oppId && isCompletedMatch) { const oppCraw = oppCountryResolved; const oppC = (oppCraw && oppCraw !== "none" && oppCraw !== "Earth" && oppCraw !== "null") ? String(oppCraw).toLowerCase() : null; if (oppC && oppC === myCountry) { if (!localOpp[oppId]) { localOpp[oppId] = { id: oppId, name: oppName, country: oppCraw, lastElo: null, lastOnline: null, matches: 0, ranked: 0, wins: 0, losses: 0 }; } if (localOpp[oppId].lastElo === null && oppElo != null) localOpp[oppId].lastElo = Math.round(oppElo); if (localOpp[oppId].lastOnline === null) { localOpp[oppId].lastOnline = opp?.["last-online"] || userLastOnlineById[String(oppId)] || null; } localOpp[oppId].matches++; if (isRankedFull) localOpp[oppId].ranked++; if (a.winner === 0 || a.winner === 1) { if (won) localOpp[oppId].wins++; else localOpp[oppId].losses++; } } } // Upset win/loss — DOAR meciuri incheiate (nu aborted) // Un meci e considerat incheiat cand un jucator a castigat cel putin 2 seturi complete. const upsetRounds = roundsMap[mid] || []; let upsetHomeSetsWon = 0, upsetAwaySetsWon = 0; upsetRounds.forEach(r => { if (setIsComplete(r["home-score"], r["away-score"])) { if (r["home-score"] > r["away-score"]) upsetHomeSetsWon++; else upsetAwaySetsWon++; } }); const upsetIsAborted = upsetHomeSetsWon < 2 && upsetAwaySetsWon < 2; if (!upsetIsAborted) { if (won && diff > 0) { const sortedRounds = [...upsetRounds].sort((a, b) => new Date(a["created-at"]) - new Date(b["created-at"])); const sets = sortedRounds.map(r => ({ my: isHome ? r["home-score"] : r["away-score"], opp: isHome ? r["away-score"] : r["home-score"], })); if (diff >= 100) { signatureVictoryTop.push({ diff, oppName, oppId, oppElo, date, oppCountry: oppCountryResolved, sets, isRanked, eloChange: a["elo-change"] ?? 0, ...ownerInfo }); } if (!bestUpsetWin || diff > bestUpsetWin.diff) { bestUpsetWin = { diff, oppName, oppId, oppElo, myElo, date, oppCountry: oppCountryResolved, sets, isRanked, eloChange: a["elo-change"] ?? 0, ...ownerInfo }; } } if (!won && diff < 0) { const abs = Math.abs(diff); if (abs >= 100) { const sortedRounds = [...upsetRounds].sort((a, b) => new Date(a["created-at"]) - new Date(b["created-at"])); const sets = sortedRounds.map(r => ({ my: isHome ? r["home-score"] : r["away-score"], opp: isHome ? r["away-score"] : r["home-score"], })); underdogStingTop.push({ diff: abs, oppName, oppId, oppElo, date, oppCountry: oppCountryResolved, sets, isRanked, eloChange: a["elo-change"] ?? 0, ...ownerInfo }); } if (!worstUpsetLoss || abs > worstUpsetLoss.diff) { const sortedRounds = [...upsetRounds].sort((a, b) => new Date(a["created-at"]) - new Date(b["created-at"])); const sets = sortedRounds.map(r => ({ my: isHome ? r["home-score"] : r["away-score"], opp: isHome ? r["away-score"] : r["home-score"], })); worstUpsetLoss = { diff: abs, oppName, oppId, oppElo, myElo, date, oppCountry: oppCountryResolved, sets, isRanked, eloChange: a["elo-change"] ?? 0, ...ownerInfo }; } } } // Statistici abandon — DOAR ranked (inclusiv meciuri abandonate cu elo-change != null) if (isRankedFull) { totalRanked++; const abRounds = roundsMap[mid] || []; const abTotalPts = abRounds.reduce((s, r) => s + (r["home-score"] || 0) + (r["away-score"] || 0), 0); const abHomeSets = abRounds.filter(r => setIsComplete(r["home-score"], r["away-score"]) && r["home-score"] > r["away-score"]).length; const abAwaySets = abRounds.filter(r => setIsComplete(r["home-score"], r["away-score"]) && r["away-score"] > r["home-score"]).length; const abIsAborted = abHomeSets < 2 && abAwaySets < 2; if (abIsAborted) { const eloChange = a["elo-change"] ?? 0; if (!won) { // Eu am abandonat if (abTotalPts > 0) { abandonMeImpact++; abandonMeImpactElo += eloChange; } else { abandonMeZero++; } } else { // Adversarul a abandonat if (abTotalPts > 0) { abandonOppImpact++; abandonOppImpactElo += eloChange; } } } } // Streaks — DOAR ranked; meciurile unranked sunt ignorate complet (nu rup seria) if (isRanked) { if (won) { if (curWin === 0) { curWinEndDate = date; // primul win procesat = cel mai recent = sfarsit cronologic curWinEndElo = Number.isFinite(myElo) ? Math.round(myElo) : null; } curWin++; curLoss = 0; curLossEndDate = null; curLossEndElo = null; if (curWin > maxWin) { maxWin = curWin; winStreakEnd = curWinEndDate; // cel mai recent meci din serie winStreakStart = date; // cel mai vechi meci din serie (pana acum) winStreakEndElo = curWinEndElo; } } else { if (curLoss === 0) { curLossEndDate = date; curLossEndElo = Number.isFinite(myElo) ? Math.round(myElo) : null; } curLoss++; curWin = 0; curWinEndDate = null; curWinEndElo = null; if (curLoss > maxLoss) { maxLoss = curLoss; lossStreakEnd = curLossEndDate; lossStreakStart = date; lossStreakEndElo = curLossEndElo; } } } // Biggest Comeback: pierdut primul set, castigat meciul — DOAR ranked if (won && isRanked) { const rounds = roundsMap[mid] || []; if (rounds.length >= 2) { const sorted = [...rounds].sort((a, b) => new Date(a["created-at"]) - new Date(b["created-at"])); const firstSet = sorted[0]; const myFirstSet = isHome ? firstSet["home-score"] : firstSet["away-score"]; const oppFirstSet = isHome ? firstSet["away-score"] : firstSet["home-score"]; if (myFirstSet < oppFirstSet && setIsComplete(firstSet["home-score"], firstSet["away-score"])) { const deficit = oppFirstSet - myFirstSet; const sets = sorted.map(r => ({ my: isHome ? r["home-score"] : r["away-score"], opp: isHome ? r["away-score"] : r["home-score"], })); biggestComebackTop.push({ deficit, oppName, oppId, oppElo, date, oppCountry: oppCountryResolved, sets, isRanked: true, eloChange: a["elo-change"] ?? 0, ...ownerInfo }); if (!bestComeback || deficit > bestComeback.deficit) { bestComeback = { deficit, oppName, oppId, oppElo, myElo: Number.isFinite(myElo) ? Math.round(myElo) : null, date, oppCountry: oppCountryResolved, sets, eloChange: a["elo-change"] ?? 0, ...ownerInfo }; } } } } // Longest Match: relevanta primara = puncte jucate (anti-stall), // tiebreaker: nr. seturi, apoi durata estimata. const rounds = roundsMap[mid] || []; if (rounds.length > 0 && date) { const matchTs = new Date(date).getTime(); const durMs = estimateMatchDurMs(rounds, matchTs); const pointsPlayed = rounds.reduce((sum, r) => sum + (Number(r["home-score"] || 0) + Number(r["away-score"] || 0)), 0); const setCount = rounds.length; if (pointsPlayed > 0) { const sortedLR = [...rounds].sort((r1, r2) => new Date(r1["created-at"]) - new Date(r2["created-at"])); const longestSets = sortedLR.map(r => ({ my: isHome ? r["home-score"] : r["away-score"], opp: isHome ? r["away-score"] : r["home-score"], })); const oppEloForLongest = oppElo != null ? Math.round(oppElo) : null; const candidate = { durMs, pointsPlayed, setCount, isRanked: !!isRankedFull, won: !!won, eloChange: Number.isFinite(Number(a["elo-change"])) ? Number(a["elo-change"]) : 0, oppName, oppId, oppElo: oppEloForLongest, myElo: Number.isFinite(myElo) ? Math.round(myElo) : null, date, oppCountry: oppCountryResolved, sets: longestSets, ...ownerInfo }; longestMatchTop.push(candidate); if (!longestMatch || candidate.pointsPlayed > (longestMatch.pointsPlayed || 0) || (candidate.pointsPlayed === (longestMatch.pointsPlayed || 0) && candidate.setCount > (longestMatch.setCount || 0)) || (candidate.pointsPlayed === (longestMatch.pointsPlayed || 0) && candidate.setCount === (longestMatch.setCount || 0) && candidate.durMs > (longestMatch.durMs || 0))) { longestMatch = candidate; } } } // Fast & Furious: cel mai scurt meci castigat dupa puncte jucate. if (won && isCompletedMatch && !isForfeitedZero && matchRounds.length > 0) { const sortedFF = [...matchRounds].sort((r1, r2) => new Date(r1["created-at"]) - new Date(r2["created-at"])); const pointsPlayed = sortedFF.reduce((sum, r) => sum + (Number(r["home-score"] || 0) + Number(r["away-score"] || 0)), 0); if (pointsPlayed > 0) { const ffSets = sortedFF.map(r => ({ my: isHome ? Number(r["home-score"] || 0) : Number(r["away-score"] || 0), opp: isHome ? Number(r["away-score"] || 0) : Number(r["home-score"] || 0), })); const ffMySets = ffSets.filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const ffOppSets = ffSets.filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const ffSetCount = ffSets.length; const ffDurMs = estimateMatchDurMs(sortedFF, new Date(date).getTime()); const candidate = { pointsPlayed, setCount: ffSetCount, durMs: ffDurMs, mySets: ffMySets, oppSets: ffOppSets, sets: ffSets, isRanked: !!isRankedFull, eloChange: Number.isFinite(Number(a["elo-change"])) ? Number(a["elo-change"]) : 0, date, oppName, oppId, myElo: Number.isFinite(myElo) ? Math.round(myElo) : null, oppElo: oppElo != null ? Math.round(oppElo) : null, oppCountry: oppCountryResolved, ...ownerInfo }; fastFuriousTop.push(candidate); if (!fastFurious || candidate.pointsPlayed < fastFurious.pointsPlayed || (candidate.pointsPlayed === fastFurious.pointsPlayed && candidate.setCount < fastFurious.setCount) || (candidate.pointsPlayed === fastFurious.pointsPlayed && candidate.setCount === fastFurious.setCount && candidate.durMs < fastFurious.durMs)) { fastFurious = candidate; } } } // Prime Time: construim sesiuni de joc din meciuri cu puncte jucate. if (date && matchRounds.length > 0 && totalPts > 0) { const startMs = new Date(date).getTime(); const durMs = estimateMatchDurMs(matchRounds, startMs); if (Number.isFinite(startMs) && Number.isFinite(durMs) && durMs > 0) { sessionCandidates.push({ startMs, endMs: startMs + durMs }); } } // Best Day Ever: acumulam ELO pe zile // Meciurile sunt descrescator (newest first), deci primul intalnit e cel mai recent al zilei if (dk && myElo != null && a["elo-change"] != null) { if (!dayEloMap[dk]) { // Prima intalnire a zilei = ultimul meci al zilei (cronologic) const eloEnd = myElo + (won ? a["elo-change"] : -a["elo-change"]); dayEloMap[dk] = { eloEnd, wins: 0 }; } addDayAccount(dayEloMap[dk], ownerName, ownerCountry); // Ultima intalnire a zilei = primul meci al zilei (cronologic) dayEloMap[dk].eloStart = myElo; if (won) dayEloMap[dk].wins++; } // Most Active Day: acumulam durata pe zile, excludem meciuri 0-0 (fara puncte jucate) if (dk) { const roundsMad = roundsMap[mid] || []; const totalPts = roundsMad.reduce((s, r) => s + (r["home-score"] || 0) + (r["away-score"] || 0), 0); const isEmpty = roundsMad.length > 0 && totalPts === 0; if (!isEmpty) { if (!dayMatchMap[dk]) dayMatchMap[dk] = { count: 0, durMs: 0, date: a["created-at"], playerElo: Number.isFinite(myElo) ? Math.round(myElo) : null }; addDayAccount(dayMatchMap[dk], ownerName, ownerCountry); if (dayMatchMap[dk].playerElo == null && Number.isFinite(myElo)) dayMatchMap[dk].playerElo = Math.round(myElo); dayMatchMap[dk].count++; if (roundsMad.length > 0) { const matchStart = new Date(a["created-at"]).getTime(); dayMatchMap[dk].durMs += estimateMatchDurMs(roundsMad, matchStart); } } } }); // Calculeaza Most Active Day — criteriu: cel mai lung timp de joc Object.entries(dayMatchMap).forEach(([day, v]) => { if (!mostActiveDay || v.durMs > mostActiveDay.durMs || (v.durMs === mostActiveDay.durMs && v.count > mostActiveDay.count)) { mostActiveDay = { date: day, count: v.count, durMs: v.durMs, playerElo: Number.isFinite(v.playerElo) ? v.playerElo : null, accounts: Array.isArray(v.accounts) ? v.accounts : [] }; } }); mostActiveDayTop = Object.entries(dayMatchMap) .map(([day, v]) => ({ day, count: Number(v.count || 0), durMs: Number(v.durMs || 0), playerElo: Number.isFinite(v.playerElo) ? v.playerElo : null, accounts: Array.isArray(v.accounts) ? v.accounts : [] })) .sort((a, b) => { if (b.durMs !== a.durMs) return b.durMs - a.durMs; if (b.count !== a.count) return b.count - a.count; return String(b.day || "").localeCompare(String(a.day || "")); }); // Calculeaza Best Day Ever Object.entries(dayEloMap).forEach(([day, v]) => { if (v.eloStart != null && v.eloEnd != null) { const net = Math.round(v.eloEnd - v.eloStart); if (!bestDayElo || net > bestDayElo.net) { bestDayElo = { net, date: day, wins: v.wins || 0, playerElo: Number.isFinite(v.eloEnd) ? Math.round(v.eloEnd) : null, accounts: Array.isArray(v.accounts) ? v.accounts : [] }; } } }); bestDayEverTop = Object.entries(dayEloMap) .filter(([, v]) => v.eloStart != null && v.eloEnd != null) .map(([day, v]) => ({ date: day, net: Math.round(Number(v.eloEnd || 0) - Number(v.eloStart || 0)), wins: Number(v.wins || 0), playerElo: Number.isFinite(v.eloEnd) ? Math.round(v.eloEnd) : null, accounts: Array.isArray(v.accounts) ? v.accounts : [] })) .filter((r) => Number(r.net || 0) > 0) .sort((a, b) => { if (b.net !== a.net) return b.net - a.net; if (b.wins !== a.wins) return b.wins - a.wins; return String(b.date || "").localeCompare(String(a.date || "")); }); if (sessionCandidates.length > 0) { const SESSION_GAP_MS = (Number(CFG.PRIME_TIME_SESSION_GAP_MIN || 45) || 45) * 60 * 1000; const sortedSess = sessionCandidates.slice().sort((a, b) => a.startMs - b.startMs); const sessions = []; sortedSess.forEach(item => { if (!sessions.length) { sessions.push({ startMs: item.startMs, endMs: item.endMs, matches: 1 }); return; } const last = sessions[sessions.length - 1]; if (item.startMs - last.endMs <= SESSION_GAP_MS) { last.endMs = Math.max(last.endMs, item.endMs); last.matches++; } else { sessions.push({ startMs: item.startMs, endMs: item.endMs, matches: 1 }); } }); const startMins = sessions.map(s => { const d = new Date(s.startMs); return d.getHours() * 60 + d.getMinutes(); }); const sessionDurMins = sessions.map(s => Math.max(0, (Number(s.endMs || 0) - Number(s.startMs || 0)) / 60000)); const avgStart = circularMeanMinutes(startMins); const avgDur = sessionDurMins.reduce((a, b) => a + b, 0) / Math.max(1, sessionDurMins.length); const avgEnd = (avgStart + avgDur) % 1440; primeTime = { sessions: sessions.length, avgStartMin: avgStart, avgEndMin: avgEnd }; } // Most Played Opponent — toate meciurile (ranked + unranked) let topOpp = null; Object.entries(oppAll).forEach(([id, v]) => { if (!topOpp || v.total > topOpp.total) topOpp = { id, name: v.name, total: v.total, ranked: v.ranked || 0, country: v.country, lastElo: v.lastElo || null }; }); // Rivalry / Nemesis / Favourite Victim — doar ranked (din oppCount) let topRivalry = null, nemesis = null, favVictim = null; Object.entries(oppCount).forEach(([id, v]) => { const total = v.wins + v.losses; // Rivalry: min 5 meciuri ranked, scor cat mai echilibrat if (total >= CFG.HOF_RIVALRY_MIN_MATCHES) { const balance = Math.abs(v.wins - v.losses); if (!topRivalry || total > (topRivalry.wins + topRivalry.losses) || (total === topRivalry.wins + topRivalry.losses && balance < Math.abs(topRivalry.wins - topRivalry.losses))) topRivalry = { id, name: v.name, wins: v.wins, losses: v.losses, country: v.country, lastElo: v.lastElo || null }; } // Die Hard: rata de infrangere >= 80%, prioritate: nr. meciuri (tiebreaker: rata mai mare) if (total >= CFG.HOF_MIN_MATCHES && v.losses / total >= CFG.HOF_DIE_HARD_RATE) { const nemTotal = nemesis ? nemesis.wins + nemesis.losses : -1; const nemRate = nemesis ? nemesis.losses / nemTotal : -1; if (total > nemTotal || (total === nemTotal && v.losses / total > nemRate)) nemesis = { id, name: v.name, wins: v.wins, losses: v.losses, country: v.country, lastElo: v.lastElo || null }; } // Favorite Victim: rata de victorie >= 80%, prioritate: nr. meciuri (tiebreaker: rata mai mare) if (total >= CFG.HOF_MIN_MATCHES && v.wins / total >= CFG.HOF_FAV_VICTIM_RATE) { const vicTotal = favVictim ? favVictim.wins + favVictim.losses : -1; const vicRate = favVictim ? favVictim.wins / vicTotal : -1; if (total > vicTotal || (total === vicTotal && v.wins / total > vicRate)) favVictim = { id, name: v.name, wins: v.wins, losses: v.losses, country: v.country, lastElo: v.lastElo || null }; } }); const localRivals = Object.values(localOpp).sort((a, b) => { const eloA = Number.isFinite(a.lastElo) ? a.lastElo : -1; const eloB = Number.isFinite(b.lastElo) ? b.lastElo : -1; if (eloB !== eloA) return eloB - eloA; if (b.matches !== a.matches) return b.matches - a.matches; return String(a.name || "").localeCompare(String(b.name || "")); }); const mostPlayedOppTop = Object.entries(oppAll) .map(([id, v]) => ({ id, ...v })) .sort((a, b) => { const at = Number(a.total || 0), bt = Number(b.total || 0); if (bt !== at) return bt - at; const ar = Number(a.ranked || 0), br = Number(b.ranked || 0); if (br !== ar) return br - ar; return String(a.name || "").localeCompare(String(b.name || "")); }); dieHardTop = Object.entries(oppCount) .map(([id, v]) => { const wins = Number(v.wins || 0); const losses = Number(v.losses || 0); const total = wins + losses; const lossRate = total > 0 ? losses / total : 0; return { id, name: v.name, wins, losses, total, lossRate, country: v.country, lastElo: v.lastElo || null }; }) .filter((r) => r.total >= CFG.HOF_MIN_MATCHES && r.lossRate >= CFG.HOF_DIE_HARD_RATE) .sort((a, b) => { if (b.total !== a.total) return b.total - a.total; if (b.lossRate !== a.lossRate) return b.lossRate - a.lossRate; return String(a.name || "").localeCompare(String(b.name || "")); }); favVictimTop = Object.entries(oppCount) .map(([id, v]) => { const wins = Number(v.wins || 0); const losses = Number(v.losses || 0); const total = wins + losses; const winRate = total > 0 ? wins / total : 0; return { id, name: v.name, wins, losses, total, winRate, country: v.country, lastElo: v.lastElo || null }; }) .filter((r) => r.total >= CFG.HOF_MIN_MATCHES && r.winRate >= CFG.HOF_FAV_VICTIM_RATE) .sort((a, b) => { if (b.total !== a.total) return b.total - a.total; if (b.winRate !== a.winRate) return b.winRate - a.winRate; return String(a.name || "").localeCompare(String(b.name || "")); }); longestRivalryTop = Object.entries(oppCount) .map(([id, v]) => { const wins = Number(v.wins || 0); const losses = Number(v.losses || 0); const total = wins + losses; const balance = Math.abs(wins - losses); return { id, name: v.name, wins, losses, total, balance, country: v.country, lastElo: v.lastElo || null }; }) .filter((r) => r.total >= CFG.HOF_RIVALRY_MIN_MATCHES) .sort((a, b) => { if (b.total !== a.total) return b.total - a.total; if (a.balance !== b.balance) return a.balance - b.balance; return String(a.name || "").localeCompare(String(b.name || "")); }); signatureVictoryTop.sort((a, b) => { if (b.diff !== a.diff) return b.diff - a.diff; return new Date(b.date || 0) - new Date(a.date || 0); }); underdogStingTop.sort((a, b) => { if (b.diff !== a.diff) return b.diff - a.diff; return new Date(b.date || 0) - new Date(a.date || 0); }); biggestComebackTop.sort((a, b) => { if (b.deficit !== a.deficit) return b.deficit - a.deficit; return new Date(b.date || 0) - new Date(a.date || 0); }); longestMatchTop.sort((a, b) => { if (Number(b.pointsPlayed || 0) !== Number(a.pointsPlayed || 0)) return Number(b.pointsPlayed || 0) - Number(a.pointsPlayed || 0); if (Number(b.setCount || 0) !== Number(a.setCount || 0)) return Number(b.setCount || 0) - Number(a.setCount || 0); return Number(b.durMs || 0) - Number(a.durMs || 0); }); fastFuriousTop.sort((a, b) => { if (Number(a.pointsPlayed || 0) !== Number(b.pointsPlayed || 0)) return Number(a.pointsPlayed || 0) - Number(b.pointsPlayed || 0); if (Number(a.setCount || 0) !== Number(b.setCount || 0)) return Number(a.setCount || 0) - Number(b.setCount || 0); return Number(a.durMs || 0) - Number(b.durMs || 0); }); return { bestUpsetWin, worstUpsetLoss, maxWinStreak: maxWin, winStreakStart, winStreakEnd, maxLossStreak: maxLoss, lossStreakStart, lossStreakEnd, winStreakEndElo, lossStreakEndElo, topOpp, bestComeback, longestMatch, fastFurious, primeTime, topRivalry, bestDayElo, nemesis, favVictim, mostActiveDay, signatureVictoryTop, underdogStingTop, biggestComebackTop, mostPlayedOppTop, longestMatchTop, fastFuriousTop, mostActiveDayTop, bestDayEverTop, dieHardTop, favVictimTop, longestRivalryTop, localRivals, localRivalsCountry: myCountryRaw, uniqueOpponents: uniqueOppSet.size, totalRanked, rankedHoFMatchCount, totalMatches: matches.length, abandonMeImpact, abandonMeImpactElo, abandonMeZero, abandonOppImpact, abandonOppImpactElo }; } function computeHofMaxElo(data) { const points = Array.isArray(data) ? data : (data.data || []); let maxVal = -Infinity, maxDate = null; points.forEach(p => { const v = Number(p.attributes?.["current-elo"] ?? p["current-elo"] ?? NaN); const d = p.attributes?.["created-at"] ?? p["created-at"]; if (Number.isFinite(v) && v > maxVal) { maxVal = v; maxDate = d; } }); if (!points.length || !Number.isFinite(maxVal)) { hofMaxElo = null; } else { hofMaxElo = { value: maxVal, date: maxDate }; } renderHofEloMax(); } function renderHofEloMax() { const el = document.getElementById("statPeakElo"); if (!hofMaxElo) { if (el) el.innerHTML = ""; return; } let { value, date } = hofMaxElo; // ELO-ul curent nu poate fi mai mic decat maximul — reconciliere cu profilul real-time const currentElo = currentPlayer?.attributes?.elo; if (currentElo != null && currentElo > value) { value = currentElo; } // Data corecta = data meciului care a PRODUS acest nivel ELO (elo-after = max) // elo-history inregistreaza ELO-ul inainte de meci, deci data din history e ziua // in care au INCEPUT cu acel ELO, nu ziua in care l-au ATINS const achievedDate = eloAchievedDateMap[Math.round(value)]; if (achievedDate) date = achievedDate; if (el) { const d = new Date(date); if (!Number.isFinite(d.getTime())) { el.innerHTML = `MAX: ${Math.round(value)}`; return; } const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yy = String(d.getFullYear()).slice(-2); el.innerHTML = `MAX: ${Math.round(value)} ${dd}.${mm}.${yy}`; } } function renderHof(s, scopeKey) { lastHofStats = s; lastHofScope = scopeKey; currentHofStats = s; currentHofScopeKey = scopeKey; // Refresh MAX ELO card — lastRankedMatchDate tocmai a fost setat in computeHofStats renderHofEloMax(); const grid = document.getElementById("hofGrid"); const hofScope = document.getElementById("hofScope"); grid.dataset.hofGridVariant = scopeKey === "hof_scope_full" ? "full" : "quick"; hofScope.textContent = t(scopeKey); hofScope.dataset.scope = scopeKey; const oppLink = (id, name, country) => { const flagCode = (country && country !== "none" && country !== "Earth" && country !== "null") ? country.toLowerCase() : "un"; const flag = ``; const safeNameJs = String(name || "?").replace(/\\/g, "\\\\").replace(/'/g, "\\'"); return id ? `${flag}${escHtml(name)}` : `${flag}${escHtml(name)}`; }; const topOppRanked = Number(s?.topOpp?.ranked || 0); const topOppUnranked = Math.max(0, Number(s?.topOpp?.total || 0) - topOppRanked); const topOppIcon = topOppRanked > topOppUnranked ? "🤜🤛" : "🤝"; const localRivals = Array.isArray(s?.localRivals) ? s.localRivals : []; const localTop3 = localRivals.slice(0, 3); const localCountryCode = s?.localRivalsCountry || currentPlayer?.attributes?.["country-code"] || null; const isInternationalProfile = !localCountryCode || ["none", "earth", "null", "international"].includes(String(localCountryCode).toLowerCase()); const localFlagCode = (localCountryCode && localCountryCode !== "none" && localCountryCode !== "Earth" && localCountryCode !== "null") ? String(localCountryCode).toLowerCase() : "un"; const localRivalsIcon = ``; const scopeHeaderLabel = scopeKey === "hof_scope_full" ? "ALL MATCHES" : "LAST 100"; const cards = [ // ── RÂNDUL 1: mereu vizibil ── s.bestUpsetWin ? buildHofCard("⚡", t("hof_upset_win"), (() => { const sets = s.bestUpsetWin.sets || []; const mySets = sets.filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = sets.filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const setsStr = sets.length ? ` (${sets.map(x => `${x.my}-${x.opp}`).join(" / ")})` : ""; const eloSpan = s.bestUpsetWin.isRanked && s.bestUpsetWin.eloChange !== 0 ? ` +${s.bestUpsetWin.eloChange} ELO` : ""; return `+${s.bestUpsetWin.diff} ELO diff vs ${oppLink(s.bestUpsetWin.oppId, s.bestUpsetWin.oppName, s.bestUpsetWin.oppCountry)} (${Math.round(s.bestUpsetWin.oppElo)})${s.bestUpsetWin.isRanked ? "Ranked" : "Casual"} · ${mySets}-${oppSets}${setsStr}${eloSpan}`; })(), s.bestUpsetWin.date, null, "upset_win", s.bestUpsetWin.oppId, s.bestUpsetWin.oppName) : buildHofCard("⚡", t("hof_upset_win"), t("hof_na"), null, null, "upset_win"), s.worstUpsetLoss ? buildHofCard("💀", t("hof_upset_loss"), (() => { const sets = s.worstUpsetLoss.sets || []; const mySets = sets.filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = sets.filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const setsStr = sets.length ? ` (${sets.map(x => `${x.my}-${x.opp}`).join(" / ")})` : ""; const eloSpan = s.worstUpsetLoss.isRanked && s.worstUpsetLoss.eloChange !== 0 ? ` −${s.worstUpsetLoss.eloChange} ELO` : ""; return `-${s.worstUpsetLoss.diff} ELO diff vs ${oppLink(s.worstUpsetLoss.oppId, s.worstUpsetLoss.oppName, s.worstUpsetLoss.oppCountry)} (${Math.round(s.worstUpsetLoss.oppElo)})${s.worstUpsetLoss.isRanked ? "Ranked" : "Casual"} · ${mySets}-${oppSets}${setsStr}${eloSpan}`; })(), s.worstUpsetLoss.date, null, "upset_loss", s.worstUpsetLoss.oppId, s.worstUpsetLoss.oppName) : buildHofCard("💀", t("hof_upset_loss"), t("hof_na"), null, null, "upset_loss"), s.bestComeback ? buildHofCard("🎯", t("hof_comeback"), (() => { const sets = s.bestComeback.sets || []; const mySets = sets.filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = sets.filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const setsStr = sets.length ? ` (${sets.map(x => `${x.my}-${x.opp}`).join(" / ")})` : ""; const eloSpan = s.bestComeback.eloChange ? ` +${s.bestComeback.eloChange} ELO` : ""; return `${t("hof_vs")} ${oppLink(s.bestComeback.oppId, s.bestComeback.oppName, s.bestComeback.oppCountry)}${s.bestComeback.oppElo ? ` (${Math.round(s.bestComeback.oppElo)})` : ""} Ranked · ${mySets}-${oppSets}${setsStr}${eloSpan}`; })(), s.bestComeback.date, null, "comeback", s.bestComeback.oppId, s.bestComeback.oppName) : buildHofCard("🎯", t("hof_comeback"), t("hof_na"), null, null, "comeback"), // ── RÂNDUL 2: mereu vizibil ── buildHofCard("🔥", t("hof_win_streak"), `${s.maxWinStreak} ranked ${t("hof_wins_row")}`, null, formatDateRange(s.winStreakStart, s.winStreakEnd), "win_streak"), buildHofCard("❄️", t("hof_loss_streak"), `${s.maxLossStreak} ranked ${t("hof_losses_row")}`, null, formatDateRange(s.lossStreakStart, s.lossStreakEnd), "loss_streak"), s.topOpp ? buildHofCard(topOppIcon, t("hof_top_opp"), (() => { const ranked = s.topOpp.ranked; const unranked = s.topOpp.total - s.topOpp.ranked; let sub; if (unranked === 0) sub = `${ranked} ranked ${t("hof_matches")}`; else if (ranked === 0) sub = `${unranked} casual ${t("hof_matches")}`; else sub = `${ranked} ranked ${t("hof_matches")} / ${unranked} casual`; return `${oppLink(s.topOpp.id, s.topOpp.name, s.topOpp.country)}${s.topOpp.lastElo ? ` (${s.topOpp.lastElo})` : ""} ${sub}`; })(), null, (() => { if (scopeKey === "hof_scope_full") { const note = document.getElementById("statTotalNote"); const num = note?.querySelector(".stat-peak-num")?.textContent || ""; return "ALL MATCHES"; } return "LAST 100"; })(), "top_opp", s.topOpp.id, s.topOpp.name) : buildHofCard(topOppIcon, t("hof_top_opp"), t("hof_na"), null, null, "top_opp"), // ── RÂNDUL 3: vizibil in quick + full ── s.longestMatch ? buildHofCard("⌛", t("hof_longest"), (() => { const h = Math.floor(s.longestMatch.durMs / 3600000); const m = Math.floor((s.longestMatch.durMs % 3600000) / 60000); const sec = Math.floor((s.longestMatch.durMs % 60000) / 1000); const secStr = String(sec).padStart(2, "0"); const dur = h > 0 ? `${h}h ${m}'${secStr}''` : `${m}'${secStr}''`; const lSets = s.longestMatch.sets || []; const totalPoints = Number(s.longestMatch.pointsPlayed || 0); const setsLine = `${totalPoints} pts (${dur})`; const oppLine = `${t("hof_vs")} ${oppLink(s.longestMatch.oppId, s.longestMatch.oppName, s.longestMatch.oppCountry)}${s.longestMatch.oppElo ? ` (${Math.round(s.longestMatch.oppElo)})` : ""}`; const mySets = lSets.filter(x => setIsComplete(x.my, x.opp) && x.my > x.opp).length; const oppSets = lSets.filter(x => setIsComplete(x.my, x.opp) && x.opp > x.my).length; const setsScore = lSets.length ? lSets.map(x => `${x.my}-${x.opp}`).join(" / ") : ""; const typeLabel = s.longestMatch.isRanked ? "Ranked" : "Casual"; const scoreLine = `${typeLabel} · ${mySets}-${oppSets}${setsScore ? ` (${setsScore})` : ""}`; return `${setsLine}${oppLine}${scoreLine}`; })(), s.longestMatch.date, null, "longest_match", s.longestMatch.oppId, s.longestMatch.oppName) : buildHofCard("⌛", t("hof_longest"), t("hof_na"), null, scopeHeaderLabel, "longest_match"), s.fastFurious ? buildHofCard("💥", "FAST & FURIOUS", (() => { const sets = s.fastFurious.sets || []; const setsScore = sets.length ? sets.map(x => `${x.my}-${x.opp}`).join(" / ") : ""; const h = Math.floor(Number(s.fastFurious.durMs || 0) / 3600000); const m = Math.floor((Number(s.fastFurious.durMs || 0) % 3600000) / 60000); const sec = Math.floor((Number(s.fastFurious.durMs || 0) % 60000) / 1000); const secStr = String(sec).padStart(2, "0"); const dur = h > 0 ? `${h}h ${m}'${secStr}''` : `${m}'${secStr}''`; const line1Main = `${s.fastFurious.pointsPlayed} pts (${dur})`; const line1Sub = `${t("hof_vs")} ${oppLink(s.fastFurious.oppId, s.fastFurious.oppName, s.fastFurious.oppCountry)}${s.fastFurious.oppElo ? ` (${s.fastFurious.oppElo})` : ""}`; const line2 = `${s.fastFurious.isRanked ? "Ranked" : "Casual"} · ${s.fastFurious.mySets}-${s.fastFurious.oppSets}${setsScore ? ` (${setsScore})` : ""}`; return `${line1Main}${line1Sub}${line2}`; })(), s.fastFurious.date, null, "fast_furious", s.fastFurious.oppId, s.fastFurious.oppName) : buildHofCard("💥", "FAST & FURIOUS", t("hof_na"), null, scopeHeaderLabel, "fast_furious"), s.primeTime ? buildHofCard("🕒", "PRIME TIME", (() => { const avgStart = fmtClockFromMinutes(s.primeTime.avgStartMin); const avgEnd = fmtClockFromMinutes(s.primeTime.avgEndMin); const tzShort = escHtml(getLocalTimezoneShortLabel()); return `${t("hof_prime_time_usually")} ${avgStart}-${avgEnd} ${tzShort}${t("hof_based_on_sessions", s.primeTime.sessions)}`; })(), null, scopeHeaderLabel, "prime_time") : buildHofCard("🕒", "PRIME TIME", t("hof_na"), null, scopeHeaderLabel, "prime_time"), // ── RÂNDURILE 4+: doar dupa full statistics ── ...(scopeKey === "hof_scope_full" ? [ s.mostActiveDay ? buildHofCard("📅", t("hof_active_day"), (() => { const h = Math.floor(s.mostActiveDay.durMs / 3600000); const m = Math.floor((s.mostActiveDay.durMs % 3600000) / 60000); const dur = h > 0 ? `${h}h ${m}min` : m > 0 ? `${m}min` : ""; return `${s.mostActiveDay.count} ${t("hof_matches_played")}${dur ? ` · ${dur} ${t("hof_time_played")}` : ""}`; })(), s.mostActiveDay.date, null, "most_active_day") : buildHofCard("📅", t("hof_active_day"), t("hof_na"), null, null, "most_active_day"), s.bestDayElo ? buildHofCard("📈", t("hof_best_day"), `+${s.bestDayElo.net} ELO ${s.bestDayElo.wins} ${s.bestDayElo.wins === 1 ? "victory" : "victories"}`, s.bestDayElo.date, null, "best_day") : buildHofCard("📈", t("hof_best_day"), t("hof_na"), null, null, "best_day"), isInternationalProfile ? buildHofCard(localRivalsIcon, t("hof_local_rivals"), t("hof_local_rivals_unavailable"), null, "ALL MATCHES", "local_rivals") : localTop3.length > 0 ? buildHofCard(localRivalsIcon, t("hof_local_rivals"), (() => { const localNameColCh = Math.min(18, Math.max(10, ...localTop3.map(r => String(r.name || "").length + 3))); const lines = localTop3.map((r, idx) => { const eloLabel = Number.isFinite(r.lastElo) ? r.lastElo : "—"; const rid = String(r.id || ""); const safeNameJs = String(r.name || "?").replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const rivalName = rid ? `${escHtml(r.name)}` : escHtml(r.name); return `
#${idx + 1} ${rivalName} ${eloLabel}
`; }).join(""); const moreBtn = localRivals.length > 3 ? `` : ""; return `
${lines}
${moreBtn}`; })(), null, "ALL MATCHES", "local_rivals") : buildHofCard(localRivalsIcon, t("hof_local_rivals"), t("hof_na"), null, "ALL MATCHES", "local_rivals"), s.nemesis ? buildHofCard("🛡️", t("hof_nemesis"), `${oppLink(s.nemesis.id, s.nemesis.name, s.nemesis.country)}${s.nemesis.lastElo ? ` (${s.nemesis.lastElo})` : ""} ${s.nemesis.wins + s.nemesis.losses} ${t("hof_matches")} · ${Math.round(s.nemesis.losses / (s.nemesis.wins + s.nemesis.losses) * 100)}% loss`, null, (() => { const note = document.getElementById("statTotalNote"); const num = note?.querySelector(".stat-peak-num")?.textContent || ""; return "ALL MATCHES"; })(), "nemesis", s.nemesis.id, s.nemesis.name) : buildHofCard("🛡️", t("hof_nemesis"), t("hof_na"), null, null, "nemesis"), s.favVictim ? buildHofCard("🏅", t("hof_victim"), `${oppLink(s.favVictim.id, s.favVictim.name, s.favVictim.country)}${s.favVictim.lastElo ? ` (${s.favVictim.lastElo})` : ""} ${s.favVictim.wins + s.favVictim.losses} ${t("hof_matches")} · ${Math.round(s.favVictim.wins / (s.favVictim.wins + s.favVictim.losses) * 100)}% wins`, null, (() => { const note = document.getElementById("statTotalNote"); const num = note?.querySelector(".stat-peak-num")?.textContent || ""; return "ALL MATCHES"; })(), "fav_victim", s.favVictim.id, s.favVictim.name) : buildHofCard("🏅", t("hof_victim"), t("hof_na"), null, null, "fav_victim"), s.topRivalry ? buildHofCard("⚔️", t("hof_rivalry"), `${oppLink(s.topRivalry.id, s.topRivalry.name, s.topRivalry.country)}${s.topRivalry.lastElo ? ` (${s.topRivalry.lastElo})` : ""} ${s.topRivalry.wins + s.topRivalry.losses} ${t("hof_matches")} · ${s.topRivalry.wins}-${s.topRivalry.losses}`, null, (() => { const note = document.getElementById("statTotalNote"); const num = note?.querySelector(".stat-peak-num")?.textContent || ""; return "ALL MATCHES"; })(), "rivalry", s.topRivalry.id, s.topRivalry.name) : buildHofCard("⚔️", t("hof_rivalry"), t("hof_na"), null, null, "rivalry"), ] : []), // ── RÂND ABANDON: mereu vizibil (quick sau full) ── ...(() => { const denomAb = abandonRankedDenominator(s, scopeKey); const pctAb = (n) => (denomAb > 0 ? Math.min(100, Math.round((n / denomAb) * 100)) : 0); const lineAb = (n) => `${pctAb(n)}% ${n} ${t("hof_of")} ${denomAb} ranked ${t("hof_matches")}`; return [ buildHofCard("🏳️", t("hof_abandon_me"), denomAb > 0 ? lineAb(s.abandonMeImpact) : t("hof_na"), null, scopeKey === "hof_scope_full" ? "ALL MATCHES" : "LAST 100", "forfeit_me"), buildHofCard("🚪", t("hof_abandon_me_zero"), denomAb > 0 ? lineAb(s.abandonMeZero) : t("hof_na"), null, scopeKey === "hof_scope_full" ? "ALL MATCHES" : "LAST 100", "forfeit_me_zero"), buildHofCard("🎁", t("hof_abandon_opp"), denomAb > 0 ? lineAb(s.abandonOppImpact) : t("hof_na"), null, scopeKey === "hof_scope_full" ? "ALL MATCHES" : "LAST 100", "forfeit_opp"), ]; })(), ]; grid.innerHTML = cards.join(""); const primeTimeCard = grid.querySelector('.hof-card[data-hof-type="prime_time"]'); if (primeTimeCard) { const tzFull = escHtml(getLocalTimezoneLabel()); const primeTipHtml = `${t("tip_prime_time")}
${t("hof_timezone_tooltip", `${tzFull}`)}`; primeTimeCard.removeAttribute("data-tooltip"); primeTimeCard.setAttribute("data-tooltip-html", primeTipHtml); } } function hofMoreAction(hofType) { if (hofType === "upset_win") return "openSignatureVictoryModal();"; if (hofType === "upset_loss") return "openUnderdogStingModal();"; if (hofType === "comeback") return "openBiggestComebackModal();"; if (hofType === "top_opp") return "openMostPlayedOppModal();"; if (hofType === "longest_match") return "openLongestMatchModal();"; if (hofType === "fast_furious") return "openFastFuriousModal();"; if (hofType === "most_active_day") return "openMostActiveDayModal();"; if (hofType === "best_day") return "openBestDayEverModal();"; if (hofType === "nemesis") return "openDieHardModal();"; if (hofType === "fav_victim") return "openFavVictimModal();"; if (hofType === "rivalry") return "openLongestRivalryModal();"; return "return false;"; } function hofMoreTooltip(hofType) { if (hofType === "upset_win") return t("tip_more_upset_win"); if (hofType === "upset_loss") return t("tip_more_upset_loss"); if (hofType === "comeback") return t("tip_more_comeback"); if (hofType === "top_opp") return t("tip_more_top_opp"); if (hofType === "longest_match") return t("tip_more_longest_match"); if (hofType === "fast_furious") return t("tip_more_fast_furious"); if (hofType === "most_active_day") return t("tip_more_most_active_day"); if (hofType === "best_day") return t("tip_more_best_day"); if (hofType === "nemesis") return t("tip_more_nemesis"); if (hofType === "fav_victim") return t("tip_more_fav_victim"); if (hofType === "rivalry") return t("tip_more_rivalry"); return t("tip_more_default"); } function buildHofCard(icon, title, valueHtml, date, headerText, hofType, oppId = null, oppName = "") { const tooltipKeyByType = { fast_furious: "tip_fast_furious", prime_time: "tip_prime_time", }; const tooltipKeyMap = { [t("hof_upset_win")]: "tip_upset_win", [t("hof_upset_loss")]: "tip_upset_loss", [t("hof_top_opp")]: "tip_top_opp", [t("hof_win_streak")]: "tip_win_streak", [t("hof_loss_streak")]: "tip_loss_streak", [t("hof_comeback")]: "tip_comeback", [t("hof_longest")]: "tip_longest", [t("hof_rivalry")]: "tip_rivalry", [t("hof_best_day")]: "tip_best_day", [t("hof_nemesis")]: "tip_nemesis", [t("hof_victim")]: "tip_victim", [t("hof_active_day")]: "tip_active_day", [t("hof_abandon_me")]: "tip_abandon_me", [t("hof_abandon_me_zero")]: "tip_abandon_me_zero", [t("hof_abandon_opp")]: "tip_abandon_opp", }; const tooltipKey = tooltipKeyByType[hofType] || tooltipKeyMap[title] || ""; const tooltip = tooltipKey ? t(tooltipKey) : ""; const leftLabel = headerText ? headerText : date ? formatDate(date) : "—"; const hideShareBtn = hofType === "forfeit_me" || hofType === "forfeit_me_zero" || hofType === "forfeit_opp" || hofType === "local_rivals"; const showMoreBtn = (hofType !== "local_rivals") && (!!oppId || hofType === "most_active_day" || hofType === "best_day"); return `
${leftLabel}
${title}
${hideShareBtn ? "" : ``}
${icon}
${valueHtml}
${showMoreBtn ? `` : ""}
`; } // ── INITIALIZARE ────────────────────────────────────────── // ── CONTACT MODAL ───────────────────────────────────────── function openContact() { document.getElementById("contactModal").classList.remove("hidden"); document.getElementById("contactForm").style.display = "block"; document.getElementById("contactSuccess").classList.add("hidden"); document.getElementById("contactError").classList.add("hidden"); document.getElementById("contactName").value = ""; document.getElementById("contactEmail").value = ""; document.getElementById("contactMessage").value = ""; } function closeContact() { document.getElementById("contactModal").classList.add("hidden"); } async function submitContact() { const name = document.getElementById("contactName").value.trim(); const email = document.getElementById("contactEmail").value.trim(); const message = document.getElementById("contactMessage").value.trim(); const errEl = document.getElementById("contactError"); const btn = document.getElementById("modalSubmitBtn"); // Validare if (!email) { errEl.textContent = t("contact_err_email_missing"); errEl.classList.remove("hidden"); return; } if (!email.includes("@")) { errEl.textContent = t("contact_err_email_invalid"); errEl.classList.remove("hidden"); return; } if (!message) { errEl.textContent = t("contact_err_msg_missing"); errEl.classList.remove("hidden"); return; } errEl.classList.add("hidden"); btn.disabled = true; btn.textContent = "Sending..."; try { await emailjs.send("service_9utatw8", "template_o53j3ko", { from_name: name || "Anonymous", reply_to: email, message: message }); document.getElementById("contactForm").style.display = "none"; document.getElementById("contactSuccess").classList.remove("hidden"); } catch(err) { errEl.textContent = t("contact_err_send_failed"); errEl.classList.remove("hidden"); btn.disabled = false; btn.textContent = currentLang === "ro" ? "Trimite mesaj" : "Send message"; } } // ── WHAT'S NEW MODAL (user-facing `public-changelog.md`; technical history is CHANGELOG.md) ── const PUBLIC_CHANGELOG_URL = "public-changelog.md"; const CHANGELOG_FALLBACK = ` ## v1.95 — 12 May 2026 - **Peak ELO**: the highest rating on your profile card reflects your recent climb more usefully at a glance, and lines up with your full career once complete history is available. - **Full stats**: the download progress bar moves in steadier steps, and when it reaches 100% the profile catches up right away instead of pausing while everything finishes saving in the background. - **Mobile profile**: key stat cards sit in clearer pairs in portrait, and long two-word titles read more cleanly. - **Insights (Hall of Fame)**: on your phone in landscape, related cards sit together in clearer pairs and rows instead of three identical narrow columns. ## v1.92 — 12 May 2026 - **Recent matches on your phone**: when you turn it sideways, unfinished-match status uses a shorter label so it’s quicker to see who stopped playing. ## v1.91 — 11 May 2026 - **ELO Evolution improvements**: the chart now opens more reliably and shows the latest ranked matches more consistently, including the most recent ranked result as the last point. - **Chart readability polish**: opponent markers, upset highlights, and match tooltips are now displayed more consistently across the visible timeline. - **Profile activity labels**: activity-date tags around player status were refined for clearer and more predictable display. ## v1.90 — 8 May 2026 - **Unique Opponents card**: profiles can now show a new hero card with each player's number of different opponents encountered in ElevenVR. - **Smarter availability**: the new card appears only when full profile history data is already available, keeping profile loads lightweight. - **ELO Evolution cleanup**: waived matches abandoned at 0-0 are now excluded from the ELO chart timeline so the graph reflects real rating progression. ## v1.89 — 8 May 2026 - **Profile quick links**: next to the profile share button, you now have direct buttons to open the same player on **ElevenVR** and **11'ClubHouse** in new tabs. - **Opponent context menu**: right-click quick actions now include dynamic **Copy Player ID: ...** plus one-click external profile opening on ElevenVR and 11'ClubHouse. - **Interaction polish**: interactive controls now consistently show a hand cursor, and tooltip behavior is cleaner during context-menu interactions. ## v1.88 — 8 May 2026 - **Prime Time clarity**: the card now shows a compact timezone tag next to the usual-hours range, and the tooltip explains which local timezone format is being used. - **Top Local Rivals**: the card title is now localized in all supported languages, and international profiles show a clear availability message. - **Opponent quick actions**: right-click on opponent names (in Recent Matches and Hall of Fame cards) opens a context menu with quick actions like head-to-head history, opening profiles in new tabs, copying player ID, and external profile links. ## v1.87 — 7 May 2026 - **Language UX**: match and Hall of Fame tooltips are now more consistent across all supported languages, including share/more actions and match-status messages. - **Match table**: country-name tooltips above opponent flags now follow the currently selected app language immediately when you switch language. ## v1.86 — 6 May 2026 - **ELO Evolution**: the chart tooltip now shows richer match context — match date, ELO gain/loss, both player names with pre-match ELO, and set scores. - **ELO Evolution**: ranked matches with **zero ELO impact** are now hidden from the chart timeline so the curve reflects only rating-changing matches. - **Terminology update**: match type labels now use **Casual** instead of **Unranked** across the interface. ## v1.85 — 4 May 2026 - **Top Local Rivals**: long rival lists are **paginated** with simple page controls, and activity status loads **only for the players on the page you’re viewing** — the modal stays fast even with thousands of same-country opponents. - **Top Local Rivals**: **Show only active accounts** now respects activity info we already know (including what was fetched earlier in your session). ## v1.84 — 3 May 2026 - **Hall of Fame** — Signature Victory & Underdog Sting: each listed match can show **how many ELO points** you gained or lost in that match (easy-to-read green/red styling). - **Profile**: if someone hasn’t been active for a long time, **Last online** shows the **month and year** of last activity instead of a very large day count. - **Profile**: activity status badges now include **short explanations** when you hover over them. ## v1.83 — 3 May 2026 - Each player profile can show **first active** — the **earliest match date** we can display for that player, **next to the player name** on the same row - **Per-day** lines in the match list (how many games, time, and ELO for that day) now stay **aligned with the full calendar day**, even if you need to **load more** to see every match from that day ## v1.82 — 3 May 2026 - **Smoother experience** when moving around the app: viewing profiles, match history, Hall of Fame cards, rivals, combined profiles, the ELO chart, and head-to-head - **Snappier** when you open the same views again during a single visit ## v1.81 — 2 May 2026 - The ELO chart and ELO history in **Multiple Accounts** can **open more quickly** in common cases ## v1.80 — 2 May 2026 - ELO evolution chart updated to use the current Eleven data feed so rating history displays correctly again - The same improvement applies to ELO history inside Multiple Accounts combined profiles ## v1.79 — 30 Apr 2026 - New ELO chart features: opponent-ELO markers and upset indicators are now shown directly on the graph - ELO chart readability and clarity improved (clearer visual emphasis for key highs/lows and cleaner marker behavior) - More stable experience when your **connection** or the **service** is briefly unavailable - You can still **open player profiles** when **live data** can’t be refreshed right away ## v1.78 — 29 Apr 2026 - Navigation flow between combined and standard profiles was refined for a smoother back experience - H2H re-entry context handling was improved on combined profiles - Most Viewed handling for combined profiles was tuned for more predictable visibility - Changelog links now render and navigate more consistently across local and hosted environments ## v1.77 — 29 Apr 2026 - Multiple Accounts and standard profiles now use an improved data-management loading flow for faster and more efficient recent-match retrieval - Recent matches pagination behavior was refined for better consistency across profile views - Combined profile reliability and tracking were improved for a more stable sharing/loading experience - General UX and changelog rendering polish ## v1.76 — 29 Apr 2026 - New major feature: Multiple Accounts combined profile (main + secondary accounts in one unified view) - Combined profile merges hero stats, matches, insights and ELO evolution across selected accounts - Added clean combined-profile sharing flow with rich social preview support - Added dynamic loading overlay while combined data is prepared - Added clickable Match ID tag in Recent Matches (copies Match ID to clipboard) ## v1.75 — 28 Apr 2026 - Mobile modal refinements for readability and compactness across HOF dialogs - Longest Match score column updated: bold overall score, per-set scores stacked, tighter row spacing - Best Day Ever mobile table header now shows WINS, and mobile date two-line rendering uses equal font size ## v1.74 — 28 Apr 2026 - Mobile UI optimization across all HOF modals: compact column headers (Pts/Dur/T/R/U/Rate/Time/W), smaller fonts and padding - Date columns in all modals now display day+month on first line and year below on mobile screens - Fixed truncated column headers in MOST PLAYED OPPONENT, MOST ACTIVE DAY, BEST DAY EVER, LONGEST MATCH, FAST & FURIOUS - Fixed "Casual" text bleeding into Score column in SIGNATURE VICTORY and UNDERDOG STING on mobile ## v1.73 — 28 Apr 2026 - Main matches table now opens head-to-head history directly from opponent name click (same pattern as other interfaces) - Added profile-link tooltip on opponent name in H2H modal and fixed its visibility/clipping behavior - Additional UX consistency refinements for profile navigation between matches list, H2H modal, and player profile ## v1.72 — 28 Apr 2026 - Completed i18n coverage for newly added modal/table labels and tooltips across all supported languages - Replaced remaining language-specific hardcoded UI fragments with translation keys - Improved ranked-scope subtitle localization consistency in all modal contexts ## v1.71 — 28 Apr 2026 - Added new detailed modals for DIE HARD, FAVORITE VICTIM, and LONGEST RIVALRY cards - Improved ranked-only scope subtitles and consistency across ranked-based modals - Expanded UI translation coverage (including table headers and multiple modal labels/tooltips) for English and Romanian ## v1.70 — 27 Apr 2026 - Faster full stats experience through major data-management improvements - Better loading flow for large player histories - Performance and responsiveness improvements for heavy profiles ## v1.69 — 26 Apr 2026 - Improved full stats processing reliability on large histories - Better consistency for long-running full stats calculations - Improved diagnostics for full-stats processing quality ## v1.68 — 24 Apr 2026 - Improved profile and insights cards sharing experience - Better social preview behavior for shared links - General UX refinements for match exploration and navigation ## v1.64 — 21 Apr 2026 - Match duration: last set duration now estimated proportionally (points played per set) instead of being silently dropped — affects match row, day banners, insights cards Longest Match and insights cards Most Active Day - Day banner match count: fixed — only fully completed matches counted (aborted and forfeited excluded) - RANKED %: fixed 104% bug — now uses exact total from a dedicated page[size]=1 request with consistent numerator/denominator - Load 100 more: fixed — last partial page was sometimes skipped; corrected using exact total match count - Die Hard / Favorite Victim: now require ≥80% rate; priority by number of matches (not rate) — prevents same player appearing on both cards - Longest Rivalry: minimum matches lowered from 5 to 3 - MAX ELO card: fixed date — now shows when the peak ELO was actually achieved, not the last ranked match date - MAX ELO card: fixed value — now correctly reflects current ELO when it exceeds historical maximum - Share button: added next to player name — copies profile URL (including language) to clipboard with ✓ feedback animation - Language in URL hash: fixed — all 9 languages now recognised (previously only en/de/fr) - "Forfeited" / "abandoned by" labels in match table now translate with language change - "OPP. FORFEITS" card renamed to "OPPONENT FORFEITS" across all languages (no more abbreviations) - Search button: "DEEP SEARCH" label added in Barlow Condensed next to the magnifying glass icon ## v1.63 — 20 Apr 2026 - Landing page: new collapsible "ABOUT / FAQ" section added below Most Viewed - Match table: score tooltip now shows player names in bold on the first line, followed by the existing content ## v1.62 — 20 Apr 2026 - Added 6 new interface languages: Portuguese (🇧🇷), Spanish (🇪🇸), Swedish (🇸🇪), Polish (🇵🇱), Italian (🇮🇹), Romanian (🇷🇴) - All UI labels, tooltips and insights cards fully translated in every new language - Language selector dropdown updated with flags and codes for all 9 supported languages ## v1.61 — 19 Apr 2026 - Match table: ELO diff pill now shows a specific tooltip with actual player names and exact point difference (e.g. "At match time, PlayerX had 164 more ELO points than PlayerY") — replaces the previous generic explanation - Match table: tooltip for unranked matches simplified — no longer lists both players; now shows "Unranked match — no ELO change" - Match table: tooltip for mid-game abandoned matches (✕ badge) now identifies who abandoned — "PlayerX abandoned the match after points were played" — instead of a generic "X vs Y — not completed" - Mobile: tapping the set score (e.g. "2-1") now shows a tooltip with the full per-set breakdown (e.g. "11-7 / 7-11 / 13-11") - Fix: ELO diff pill tooltip was not visible on desktop — pill changed to inline-block and generic cell tooltip removed to avoid conflict ## v1.60 — 19 Apr 2026 - Mobile: ELO Evolution chart now stretches edge-to-edge — both axes hidden on small screens, chart fills full width - Mobile: ELO chart shows first & last date at bottom edge; MAX/MIN badges include a compact date line (e.g. "Apr '26") - Mobile: match table overhauled — arrow column removed, proportional grid (55% name / flexible score / fixed ELO), opponent name truncates gracefully while always preserving the ELO diff pill - Mobile: HOF section title abbreviated for compact layout - Match table: win/loss arrow replaced with neutral grey bar for all matches with no ELO impact (unranked, not ranked, forfeited) — previously only abandoned matches showed the bar - Mobile: FORFEITED label now distinguishes who quit — "QUIT" in orange (current player) vs "FORFEIT" in red (opponent) - Mobile: abandoned mid-game matches show a ✕ badge before the ELO value to signal the match was not played to completion ## v1.59 — 18 Apr 2026 - ELO Evolution chart: interactive crosshair — vertical line, hover dot and tooltip (date + ELO) follow the cursor across the chart - Fix: Signature Victory & Underdog Sting now correctly exclude unranked matches from ranked detection (matches with ranked: false no longer shown as "Ranked" on the card) - HOF abandon cards: sub-line now shows N of X ranked matches instead of just N ranked matches, making the percentage immediately verifiable - Fix: tooltips for Player Forfeits, Early Exits and Opp. Forfeits updated — removed incorrect mention of ELO gained/lost ## v1.58 — 18 Apr 2026 - HOF section now open by default when a profile loads - Fix: match table no longer shows "-0" for matches with 0 ELO change - Ranked % hero card: now calculated from HOF stats (accurate ranked count, including forfeited matches); overridden with full-history count after full stats calculation - Fix: 0-0 abandoned ranked matches with elo-change: 0 now correctly shown as FORFEITED instead of NOT RANKED - Match table: added NOT RANKED label for matches with elo-change: 0 on a completed score (ambiguous API data — may be unranked or a ranked match with no ELO applied) ## v1.57 — 18 Apr 2026 - HOF: Signature Victory & Underdog Sting — added ranked/unranked indicator and ELO points gained/lost - HOF: Biggest Comeback — added ranked indicator, ELO gained, and opponent's current ELO - HOF: Win Streak & Loss Streak — clarified as ranked-only in sub-line - HOF: Most Played Opponent — sub-line now shows breakdown: X ranked / Y unranked - HOF: Longest Match — added set scores - HOF: Best Day Ever — added victory count - HOF: Die Hard, Favorite Victim, Longest Rivalry — opponent's current ELO displayed next to name - HOF: Player Forfeits, Early Exits, Opp. Forfeits — removed ELO totals; replaced with ranked match count ## v1.56 — 18 Apr 2026 - Hero: added 7th stat card showing percentage of ranked matches out of total matches played - HOF full stats: added 3 new cards (Player Forfeits, Early Exits, Opp. Forfeits), calculated relative to total ranked matches - HOF grows from 12 to 15 cards after full stats calculation ## v1.55 — 08 Apr 2026 - Search: added deep search button — paginates through API results until a matching player is found or all pages are exhausted - Search: results sorted by relevance (exact match → starts with → contains) - Search: purely numeric input triggers a direct player ID lookup in parallel - Search: query sent to API in uppercase for more consistent results - Search: result pool accumulated across successive requests for the same query (dedup by ID) ## v1.54 — 08 Apr 2026 - Signature Victory & Underdog Sting: abandoned matches now filtered out — only counts matches where at least 2 complete sets were won - GA4: corrected tracking tag ID; added view_player event on every profile view ## v1.53 — 29 Mar 2026 - Language selector replaced with a dropdown (flag + code + arrow) — reduces header clutter, especially on mobile ## v1.52 — 29 Mar 2026 - Eliminated two redundant network requests on profile load — total match count now extracted from existing HOF response - Unofficial disclaimer added to title, meta tags, OG/Twitter, landing page and footer ## v1.50 — 29 Mar 2026 - Longest Match: duration now shown in MM'SS'' format instead of minutes only - Die Hard / Favourite Victim: minimum match threshold lowered from 5 to 3 ranked matches ## v1.49 — 29 Mar 2026 - Fix: hero layout broken — player name and stat boxes were overlapping - Fix: HOF card tooltips not appearing due to incorrect overflow:hidden placement ## v1.46 — 29 Mar 2026 - Fix: Most Played Opponent was incorrectly filtered to ranked-only; now counts all matches (ranked + unranked) - Fix: Biggest Comeback was not filtered to ranked matches only - Biggest Comeback: displays all set scores instead of only the first set ## v1.45 — 29 Mar 2026 - Fix: Win Streak and Loss Streak now calculated from ranked matches only (unranked matches no longer break a streak) - Fix: Win/Loss Streak date now shows the full interval (e.g. "13–17 Oct 2022") instead of only the start date - Die Hard, Favourite Victim, Longest Rivalry: recalculated from ranked matches only - Most Active Day: criterion changed from most matches played to longest total play time; 0-0 matches excluded - Day separator: 0-0 matches excluded from displayed match count ## v1.44 — 27 Mar 2026 - Landing: "Start typing a name above" hint is now clickable and focuses the search box ## v1.41–v1.43 — 27 Mar 2026 - Fix i18n: multiple labels (HOF toggle, WIN RATE, CLEAR HISTORY, contact form errors) were not translating on language change ## v1.40 — 27 Mar 2026 - Fix: duplicate i18n key causing incorrect button label during match load ## v1.39 — 27 Mar 2026 - ETTSTATS logo is now clickable — returns to the landing page - Landing: added MOST VIEWED section showing the 6 most visited profiles with flag, name, ELO, rank and visit count ## v1.38 — 27 Mar 2026 - Full stats calculation: page size increased from 25 to 100 — 4× fewer API requests, significantly faster ## v1.37 — 27 Mar 2026 - ELO Evolution: chart now loads lazily on first open (no request on profile load if section is closed) - ELO Evolution: added date labels on X axis, max/min pills, limit extended to 150 points ## v1.36 — 27 Mar 2026 - ELO Evolution section reactivated and moved before the HOF section ## v1.33 — 26 Mar 2026 - Fix: "Calculate full statistics" button not reappearing correctly when navigating back to a profile - HOF full stats loaded from cache automatically, without requiring button click ## v1.32 — 26 Mar 2026 - Fix: ELO history cache was storing the full array (~600 KB per player); now stores only the max ELO value ## v1.31 — 26 Mar 2026 - Fix: cache was storing unnecessary data; per-player footprint reduced from ~5 MB to ~120 KB ## v1.30 — 26 Mar 2026 - localStorage cache implemented: profile + matches 60 min, ELO history 2h, HOF full stats 12h - Expired entries deleted automatically on app start ## v1.25 — 26 Mar 2026 - New HOF card: Most Active Day (total play time + match count) - International flag shown for players without a country code - Fix: ELO values rounded to integer throughout (hero, peak ELO, HOF cards, search results) ## v1.21–v1.23 — 26 Mar 2026 - Country flags added next to opponent names in HOF cards - Fix: flags not updating on repeated occurrences of the same opponent ## v1.19–v1.20 — 26 Mar 2026 - Fix: search dropdown was appearing behind other page sections ## v1.17 — 26 Mar 2026 - Opponent country flag displayed next to name in the match table ## v1.14–v1.16 — 26 Mar 2026 - Custom tooltips added to hero stat boxes, match table columns and HOF cards — translated in EN/DE/FR, update on language change - Fix: hero tooltips were appearing behind the header ## v1.07 — 26 Mar 2026 - ELO difference column: added tooltip explaining green/red colour (translated EN/DE/FR) - Desktop: score displayed as overall result + per-set breakdown (e.g. 2-1 · 11-7 / 7-11 / 13-11) ## v1.05 — 26 Mar 2026 - Custom tooltips with 0.5s delay implemented across the entire interface ## v1.04 — 26 Mar 2026 - ELO diff now uses > / < symbols instead of + / - to avoid confusion with gain/loss - Fix: ELO diff column alignment on mobile ## v1.02–v1.03 — 26 Mar 2026 - Mobile: match table shows ELO diff and overall score (2-1) instead of per-set scores - Mobile: ELO diff displayed inline after opponent name on small screens ## v1.01 — 25 Mar 2026 - Mobile responsive layout: compact header, hero stats on 3×2 grid, HOF in single column ## v1.0 — 25 Mar 2026 - Public launch on ettstats.eu - Player search, profile view, ELO history chart, Hall of Fame, match history with pagination - localStorage caching, EN/DE/FR localisation - SEO meta tags, Open Graph, Twitter Card, Google Analytics - Contact modal (EmailJS) `; async function openChangelog() { const modal = document.getElementById("changelogModal"); const content = document.getElementById("changelogContent"); modal.classList.remove("hidden"); if (content.innerHTML) return; // already rendered try { const md = await fetch(PUBLIC_CHANGELOG_URL, { cache: "no-store" }).then(r => { if (!r.ok) throw new Error(`Failed to load ${PUBLIC_CHANGELOG_URL}`); return r.text(); }); content.innerHTML = parseMd(md); } catch (e) { content.innerHTML = parseMd(CHANGELOG_FALLBACK); } } function closeChangelog() { document.getElementById("changelogModal").classList.add("hidden"); } function parseMd(md) { let html = ""; let inList = false; md.split("\n").forEach(line => { if (line.startsWith("## ")) { if (inList) { html += ""; inList = false; } html += `

${esc(line.slice(3))}

`; } else if (line.startsWith("- ")) { if (!inList) { html += "
    "; inList = true; } html += `
  • ${inlineHtml(line.slice(2))}
  • `; } else if (line.trim() === "") { if (inList) { html += "
"; inList = false; } } }); if (inList) html += ""; return html; } function inlineHtml(text) { return text .replace(/&/g, "&").replace(//g, ">") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, href) => { const isExternal = /^https?:\/\//i.test(String(href || "")); const attrs = isExternal ? ` target="_blank" rel="noopener noreferrer"` : ""; return `${label}`; }) .replace(/`([^`]+)`/g, "$1"); } function esc(t) { return t.replace(/&/g, "&").replace(//g, ">"); } function syncModalScrollLock() { const anyModalOpen = !!document.querySelector(".modal-overlay:not(.hidden)"); document.body.style.overflow = anyModalOpen ? "hidden" : ""; } function initModalScrollLockObserver() { const obs = new MutationObserver(() => syncModalScrollLock()); obs.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["class"] }); syncModalScrollLock(); } // Inchide modalurile cu ESC document.addEventListener("keydown", e => { if (e.key !== "Escape") return; closeContact(); closeChangelog(); const svModalEl = document.getElementById("signatureVictoryModal"); if (svModalEl && !svModalEl.classList.contains("hidden")) { closeSignatureVictoryModal(); return; } const usModalEl = document.getElementById("underdogStingModal"); if (usModalEl && !usModalEl.classList.contains("hidden")) { closeUnderdogStingModal(); return; } const bcModalEl = document.getElementById("biggestComebackModal"); if (bcModalEl && !bcModalEl.classList.contains("hidden")) { closeBiggestComebackModal(); return; } const mpModalEl = document.getElementById("mostPlayedOppModal"); if (mpModalEl && !mpModalEl.classList.contains("hidden")) { closeMostPlayedOppModal(); return; } const lmModalEl = document.getElementById("longestMatchModal"); if (lmModalEl && !lmModalEl.classList.contains("hidden")) { closeLongestMatchModal(); return; } const ffModalEl = document.getElementById("fastFuriousModal"); if (ffModalEl && !ffModalEl.classList.contains("hidden")) { closeFastFuriousModal(); return; } const madModalEl = document.getElementById("mostActiveDayModal"); if (madModalEl && !madModalEl.classList.contains("hidden")) { closeMostActiveDayModal(); return; } const bdeModalEl = document.getElementById("bestDayEverModal"); if (bdeModalEl && !bdeModalEl.classList.contains("hidden")) { closeBestDayEverModal(); return; } const dhModalEl = document.getElementById("dieHardModal"); if (dhModalEl && !dhModalEl.classList.contains("hidden")) { closeDieHardModal(); return; } const fvModalEl = document.getElementById("favVictimModal"); if (fvModalEl && !fvModalEl.classList.contains("hidden")) { closeFavVictimModal(); return; } const lrModalEl = document.getElementById("longestRivalryModal"); if (lrModalEl && !lrModalEl.classList.contains("hidden")) { closeLongestRivalryModal(); return; } const matchupModalEl = document.getElementById("matchupModal"); if (matchupModalEl && !matchupModalEl.classList.contains("hidden")) { closeMatchupModal(); return; } closeLocalRivalsModal(); }); initModalScrollLockObserver(); // ── HTML TOOLTIP (desktop hover, suporta innerHTML) ──────── let _htmlTipEl = null; let _htmlTipTmr = null; function initHtmlTooltips() { _htmlTipEl = document.createElement("div"); _htmlTipEl.className = "html-tooltip"; document.body.appendChild(_htmlTipEl); document.addEventListener("mouseover", e => { if (window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX) return; const anchor = e.target.closest("[data-tooltip-html]"); if (!anchor) return; clearTimeout(_htmlTipTmr); _htmlTipTmr = setTimeout(() => { _htmlTipEl.innerHTML = anchor.getAttribute("data-tooltip-html") || ""; _htmlTipEl.style.visibility = "hidden"; _htmlTipEl.style.display = "block"; requestAnimationFrame(() => { positionHtmlTooltip(anchor); _htmlTipEl.style.visibility = ""; }); }, CFG.TOOLTIP_HOVER_DELAY_MS); }); document.addEventListener("mouseout", e => { if (window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX) return; const anchor = e.target.closest("[data-tooltip-html]"); if (!anchor) return; if (!anchor.contains(e.relatedTarget)) { clearTimeout(_htmlTipTmr); _htmlTipEl.style.display = "none"; } }); } function positionHtmlTooltip(anchor) { const aRect = anchor.getBoundingClientRect(); const tRect = _htmlTipEl.getBoundingClientRect(); const margin = 8; let top = aRect.top - tRect.height - 12; const below = top < margin; if (below) top = aRect.bottom + 12; let left = aRect.left + aRect.width / 2 - tRect.width / 2; left = Math.max(margin, Math.min(left, window.innerWidth - tRect.width - margin)); _htmlTipEl.style.top = `${top}px`; _htmlTipEl.style.left = `${left}px`; _htmlTipEl.style.setProperty("--arrow-left", `${(aRect.left + aRect.width / 2) - left}px`); _htmlTipEl.classList.toggle("html-tooltip-below", below); } // ── MOBILE TAP TOOLTIPS ─────────────────────────────────── let _mobTipEl = null; let _mobTipTmr = null; function initMobileTooltips() { document.addEventListener("click", function(e) { if (window.innerWidth > CFG.MOBILE_BREAKPOINT_PX) return; // Nu intercepta tap-uri pe linkuri de navigare if (e.target.closest("a[href], a[onclick]")) { hideMobileTooltip(); return; } const anchor = e.target.closest("[data-tooltip-html], [data-tooltip]"); hideMobileTooltip(); if (!anchor) return; const html = anchor.getAttribute("data-tooltip-html"); if (html) { e.stopPropagation(); showMobileTooltip(anchor, html, true); return; } const text = anchor.getAttribute("data-tooltip"); if (!text) return; e.stopPropagation(); showMobileTooltip(anchor, text, false); }); } function showMobileTooltip(anchor, content, isHtml = false) { const el = document.createElement("div"); el.className = "mobile-tooltip"; if (isHtml) el.innerHTML = content; else el.textContent = content; document.body.appendChild(el); _mobTipEl = el; requestAnimationFrame(() => { const aRect = anchor.getBoundingClientRect(); const eRect = el.getBoundingClientRect(); const scrollY = window.scrollY || window.pageYOffset; const margin = 10; const vw = window.innerWidth; // Prefer deasupra, daca nu incape — dedesubt let top = aRect.top + scrollY - eRect.height - 10; if (top < scrollY + margin) top = aRect.bottom + scrollY + 8; // Centrat orizontal, dar pastrat in ecran let left = aRect.left + aRect.width / 2 - eRect.width / 2; left = Math.max(margin, Math.min(left, vw - eRect.width - margin)); el.style.top = `${top}px`; el.style.left = `${left}px`; }); _mobTipTmr = setTimeout(hideMobileTooltip, CFG.TOOLTIP_MOBILE_HIDE_MS); } function hideMobileTooltip() { if (_mobTipEl) { _mobTipEl.remove(); _mobTipEl = null; } if (_mobTipTmr) { clearTimeout(_mobTipTmr); _mobTipTmr = null; } } // ── INITIALIZARE ────────────────────────────────────────── document.addEventListener("DOMContentLoaded", () => { checkUrlOnLoad(); if (!location.hash || location.hash === "#") mvRender(); initHtmlTooltips(); initMobileTooltips(); // ── LANGUAGE DROPDOWN ── const dropdown = document.getElementById("langDropdown"); const selected = document.getElementById("langSelected"); const options = document.querySelectorAll(".lang-option"); selected.addEventListener("click", e => { e.stopPropagation(); dropdown.classList.toggle("open"); }); options.forEach(btn => { btn.addEventListener("click", () => { setLang(btn.dataset.lang); dropdown.classList.remove("open"); }); }); document.addEventListener("click", () => dropdown.classList.remove("open")); // Footer contact const footerBtn = document.getElementById("footerContactLink"); if (footerBtn) footerBtn.addEventListener("click", openContact); // Footer version → What's New (public release notes) const versionEl = document.querySelector(".footer-version"); if (versionEl) versionEl.addEventListener("click", openChangelog); // Modal What's New const changelogCloseBtn = document.getElementById("changelogCloseBtn"); if (changelogCloseBtn) changelogCloseBtn.addEventListener("click", closeChangelog); const changelogOverlay = document.getElementById("changelogModal"); if (changelogOverlay) changelogOverlay.addEventListener("click", e => { if (e.target === changelogOverlay) closeChangelog(); }); // Modal contact const closeBtn = document.getElementById("modalCloseBtn"); if (closeBtn) closeBtn.addEventListener("click", closeContact); const submitBtn = document.getElementById("modalSubmitBtn"); if (submitBtn) submitBtn.addEventListener("click", submitContact); const overlay = document.getElementById("contactModal"); if (overlay) overlay.addEventListener("click", e => { if (e.target === overlay) closeContact(); }); // Landing hint — click focuseaza search + ping const landingHint = document.querySelector(".landing-hint"); if (landingHint) { landingHint.addEventListener("click", (e) => { e.stopPropagation(); if (searchInput) { if (window.innerWidth <= CFG.MOBILE_BREAKPOINT_PX) { document.querySelector("header")?.classList.add("search-active"); } searchInput.focus(); const wrap = searchInput.closest(".search-wrap"); if (wrap) { wrap.classList.remove("search-ping"); void wrap.offsetWidth; wrap.classList.add("search-ping"); wrap.addEventListener("animationend", () => { wrap.classList.remove("search-ping"); }, { once: true }); } } }); } window.addEventListener("resize", updateDeepSearchTooltip); updateDeepSearchTooltip(); // ── MOBILE LANDSCAPE CLASS ────────────────────────────────────────────── // Pure-CSS media queries can fail to re-evaluate after an orientation // change on Android (layout cache keeps stale values). This JS handler // fires on every resize / orientationchange and explicitly toggles a // class on so CSS rules are always in sync with actual orientation. function applyMobileLandscapeClass() { const isTouch = navigator.maxTouchPoints > 0 || "ontouchstart" in window; const isLandscape = window.innerWidth > window.innerHeight; // "small screen" guard: tablets in landscape should not be affected const isPhone = Math.min(window.innerWidth, window.innerHeight) < 600; document.body.classList.toggle("mobile-landscape", isTouch && isLandscape && isPhone); } applyMobileLandscapeClass(); window.addEventListener("resize", applyMobileLandscapeClass); window.addEventListener("orientationchange", function () { // Short delay: let the browser finish reflowing after rotation setTimeout(applyMobileLandscapeClass, 150); }); // ──────────────────────────────────────────────────────────────────────── }); function toggleAbout() { const collapse = document.getElementById("aboutCollapse"); const icon = document.getElementById("aboutIcon"); if (!collapse) return; const isOpen = collapse.classList.toggle("open"); icon.textContent = isOpen ? "▼" : "▶"; } function toggleHof() { const collapse = document.getElementById("hofCollapse"); const icon = document.getElementById("hofIcon"); if (!collapse) return; const isOpen = collapse.classList.toggle("open"); icon.textContent = isOpen ? t("hof_hide_cards") : t("hof_show_cards"); // La deschidere: arata intotdeauna butonul (se ascunde doar dupa full stats) if (isOpen) { document.getElementById("hofActions").style.display = "block"; } } // Dev: ranked hero vs chart label consistency — call from console: __ettstatsDebugRankedCounts() if (typeof window !== "undefined") { window.__ettstatsDebugRankedCounts = function () { const rawIncomingPoints = Array.isArray(eloData?.data) ? eloData.data.length : (Array.isArray(eloData) ? eloData.length : 0); const timelineHasMatchIds = Array.isArray(eloData?.data) && eloData.data.some((p) => { const mid = p?.attributes?.["match-id"] ?? p?.["match-id"] ?? null; return mid != null && String(mid).trim() !== ""; }); logRankedCountDebug({ manual: true, finalChartPoints: eloTitlePointCount, rawIncomingPoints, timelineHasMatchIds }); }; }