`;
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 = `
`;
}
/** 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 = `
`;
}
// ── 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
});
};
}