// compare/scoreboard.js — vote history display import Storage from '../storage.js'; import state from './state.js'; import { VOTES_STORAGE_KEY } from './icons.js'; import themeModule from '../theme.js'; import uiModule from '../ui.js'; const escapeHtml = uiModule.esc; // Type icons for the mode tabs — match the Compare selector's tab icons. const _TYPE_ICONS = { chat: '', agent: '', search: '', research: '', }; /** Detect search provider names to fix legacy votes without mode. */ const _searchProviderNames = new Set(['brave search', 'duckduckgo', 'google', 'searxng', 'bing', 'tavily']); /** Guess the compare mode for a vote record (legacy votes lack a mode field). */ function _guessVoteMode(v) { if (v.mode) return v.mode; // Legacy vote — check if models look like search providers if (v.models && v.models.some(m => _searchProviderNames.has(m.toLowerCase()))) return 'search'; return 'chat'; } export function showScoreboard() { // Remove existing overlay if present const existing = document.getElementById('scoreboard-overlay'); if (existing) existing.remove(); const votes = Storage.getJSON(VOTES_STORAGE_KEY, []); // Build modal const overlay = document.createElement('div'); overlay.id = 'scoreboard-overlay'; overlay.className = 'modal'; overlay.style.zIndex = '10001'; overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); // Esc handling lives in the global "close topmost popup" handler (app.js) // so the scoreboard closes first without also dismissing the compare // window beneath it. const content = document.createElement('div'); content.className = 'modal-content'; content.style.maxWidth = '520px'; const header = document.createElement('div'); header.className = 'modal-header'; const title = document.createElement('h3'); title.innerHTML = 'Scoreboard'; title.style.margin = '0'; const closeX = document.createElement('button'); closeX.className = 'close-btn'; closeX.innerHTML = '✖'; closeX.addEventListener('click', () => overlay.remove()); header.appendChild(title); header.appendChild(closeX); content.appendChild(header); const body = document.createElement('div'); body.className = 'modal-body'; body.style.padding = '12px 16px'; // Mobile: add bottom padding so the Clear History button isn't hidden behind // Firefox's bottom URL bar / the home-indicator safe area. if (window.innerWidth <= 768) { body.style.paddingBottom = 'calc(env(safe-area-inset-bottom, 0px) + 72px)'; body.style.overflowY = 'auto'; } // Mode tabs const modes = ['chat', 'agent', 'search', 'research']; const modeLabels = { chat: 'Chat', agent: 'Agent', search: 'Search', research: 'Research' }; const tabBar = document.createElement('div'); tabBar.className = 'compare-mode-tabs'; tabBar.style.marginBottom = '12px'; let activeMode = 'chat'; function renderScoreTable() { // Clear previous table const prev = body.querySelector('.scoreboard-wrap'); if (prev) { // The Clear button was moved INTO the wrap on a prior render — rescue it // back to the body before removing the wrap, otherwise it's destroyed // with the wrap and never re-found (it vanished after visiting an empty // mode like Images and switching back). const clr = prev.querySelector('.scoreboard-clear-btn'); if (clr) body.appendChild(clr); prev.remove(); } const wrap = document.createElement('div'); wrap.className = 'scoreboard-wrap'; const filtered = votes.filter(v => _guessVoteMode(v) === activeMode); // Aggregate const stats = {}; for (const v of filtered) { for (let mi = 0; mi < v.models.length; mi++) { const m = v.models[mi]; if (!stats[m]) stats[m] = { wins: 0, losses: 0, ties: 0, games: 0, totalCost: 0, costCount: 0 }; stats[m].games++; if (v.winner === 'tie') stats[m].ties++; else if (v.winner === m) stats[m].wins++; else stats[m].losses++; if (v.costs && v.costs[mi] != null) { stats[m].totalCost += v.costs[mi]; stats[m].costCount++; } } } const sorted = Object.entries(stats).sort((a, b) => { const rateA = a[1].games ? a[1].wins / a[1].games : 0; const rateB = b[1].games ? b[1].wins / b[1].games : 0; return rateB - rateA; }); if (sorted.length === 0) { const empty = document.createElement('p'); empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;'; empty.textContent = 'No ' + activeMode + ' votes yet. Run a comparison and vote!'; wrap.appendChild(empty); } else { const table = document.createElement('table'); table.className = 'scoreboard-table'; const thead = document.createElement('thead'); thead.innerHTML = 'ModelWin%WLTGames$/1k'; table.appendChild(thead); const tbody = document.createElement('tbody'); for (const [name, s] of sorted) { const pct = s.games ? Math.round((s.wins / s.games) * 100) : 0; const avgCost = s.costCount ? (s.totalCost / s.costCount) * 1000 : null; const costStr = avgCost !== null ? ('$' + (avgCost < 1 ? avgCost.toFixed(2) : avgCost.toFixed(0))) : '—'; const tr = document.createElement('tr'); tr.innerHTML = '' + escapeHtml(name) + '' + '' + pct + '%' + '' + s.wins + '' + s.losses + '' + s.ties + '' + '' + s.games + '' + '' + costStr + ''; tbody.appendChild(tr); } table.appendChild(tbody); wrap.appendChild(table); } const total = document.createElement('div'); total.style.cssText = 'font-size:0.8em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:12px;text-align:center;'; total.textContent = filtered.length + ' vote' + (filtered.length !== 1 ? 's' : '') + ' recorded'; wrap.appendChild(total); // Move clear button into wrap so it stays at bottom const existingClear = body.querySelector('.scoreboard-clear-btn'); if (existingClear) wrap.appendChild(existingClear); body.appendChild(wrap); } modes.forEach(mode => { const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'compare-mode-tab' + (mode === activeMode ? ' active' : ''); tab.innerHTML = (_TYPE_ICONS[mode] || '') + '' + modeLabels[mode] + ''; tab.addEventListener('click', () => { activeMode = mode; tabBar.querySelectorAll('.compare-mode-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); renderScoreTable(); }); tabBar.appendChild(tab); }); body.appendChild(tabBar); renderScoreTable(); // Clear history button const clearBtn = document.createElement('button'); clearBtn.className = 'scoreboard-clear-btn'; clearBtn.textContent = 'Clear History'; clearBtn.style.cssText = 'display:block;margin:16px 0 4px auto;padding:4px 12px;background:none;border:1px solid var(--border);color:var(--fg);border-radius:4px;cursor:pointer;font-size:11px;opacity:0.4;transition:opacity 0.15s;'; clearBtn.addEventListener('mouseenter', () => { clearBtn.style.opacity = '1'; }); clearBtn.addEventListener('mouseleave', () => { clearBtn.style.opacity = '0.6'; }); clearBtn.addEventListener('click', () => { // Inline confirmation const confirmRow = document.createElement('div'); confirmRow.style.cssText = 'display:flex;gap:8px;justify-content:center;align-items:center;margin-top:8px;padding:8px 12px;border:1px solid color-mix(in srgb, var(--red) 40%, var(--border));border-radius:6px;background:color-mix(in srgb, var(--red) 5%, transparent);'; const confirmLabel = document.createElement('span'); confirmLabel.style.cssText = 'font-size:12px;opacity:0.7;'; confirmLabel.textContent = 'Clear all vote history?'; const yesBtn = document.createElement('button'); yesBtn.textContent = 'Clear'; yesBtn.style.cssText = 'padding:4px 12px;background:var(--red);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;'; yesBtn.addEventListener('click', () => { Storage.setJSON(VOTES_STORAGE_KEY, []); overlay.remove(); showScoreboard(); }); const noBtn = document.createElement('button'); noBtn.textContent = 'Cancel'; noBtn.className = 'cmp-btn-secondary'; noBtn.style.cssText = 'padding:4px 12px;border-radius:4px;font-size:12px;'; noBtn.addEventListener('click', () => confirmRow.remove()); confirmRow.appendChild(confirmLabel); confirmRow.appendChild(yesBtn); confirmRow.appendChild(noBtn); // Replace button with confirmation clearBtn.style.display = 'none'; clearBtn.parentElement.appendChild(confirmRow); }); body.appendChild(clearBtn); content.appendChild(body); overlay.appendChild(content); document.body.appendChild(overlay); if (themeModule && themeModule.makeDraggable) { themeModule.makeDraggable(content, header); } } export default { showScoreboard };