1// compare/scoreboard.js â vote history display 2import Storage from '../storage.js'; 3import state from './state.js'; 4import { VOTES_STORAGE_KEY } from './icons.js'; 5import themeModule from '../theme.js'; 6import uiModule from '../ui.js'; 7 8const escapeHtml = uiModule.esc; 9 10// Type icons for the mode tabs â match the Compare selector's tab icons. 11const _TYPE_ICONS = { 12 chat: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>', 13 agent: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>', 14 search: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', 15 research: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>', 16}; 17 18/** Detect search provider names to fix legacy votes without mode. */ 19const _searchProviderNames = new Set(['brave search', 'duckduckgo', 'google', 'searxng', 'bing', 'tavily']); 20 21/** Guess the compare mode for a vote record (legacy votes lack a mode field). */ 22function _guessVoteMode(v) { 23 if (v.mode) return v.mode; 24 // Legacy vote â check if models look like search providers 25 if (v.models && v.models.some(m => _searchProviderNames.has(m.toLowerCase()))) return 'search'; 26 return 'chat'; 27} 28 29export function showScoreboard() { 30 // Remove existing overlay if present 31 const existing = document.getElementById('scoreboard-overlay'); 32 if (existing) existing.remove(); 33 34 const votes = Storage.getJSON(VOTES_STORAGE_KEY, []); 35 36 // Build modal 37 const overlay = document.createElement('div'); 38 overlay.id = 'scoreboard-overlay'; 39 overlay.className = 'modal'; 40 overlay.style.zIndex = '10001'; 41 overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 42 // Esc handling lives in the global "close topmost popup" handler (app.js) 43 // so the scoreboard closes first without also dismissing the compare 44 // window beneath it. 45 46 const content = document.createElement('div'); 47 content.className = 'modal-content'; 48 content.style.maxWidth = '520px'; 49 50 const header = document.createElement('div'); 51 header.className = 'modal-header'; 52 const title = document.createElement('h3'); 53 title.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Scoreboard'; 54 title.style.margin = '0'; 55 const closeX = document.createElement('button'); 56 closeX.className = 'close-btn'; 57 closeX.innerHTML = '✖'; 58 closeX.addEventListener('click', () => overlay.remove()); 59 header.appendChild(title); 60 header.appendChild(closeX); 61 content.appendChild(header); 62 63 const body = document.createElement('div'); 64 body.className = 'modal-body'; 65 body.style.padding = '12px 16px'; 66 // Mobile: add bottom padding so the Clear History button isn't hidden behind 67 // Firefox's bottom URL bar / the home-indicator safe area. 68 if (window.innerWidth <= 768) { 69 body.style.paddingBottom = 'calc(env(safe-area-inset-bottom, 0px) + 72px)'; 70 body.style.overflowY = 'auto'; 71 } 72 73 // Mode tabs 74 const modes = ['chat', 'agent', 'search', 'research']; 75 const modeLabels = { chat: 'Chat', agent: 'Agent', search: 'Search', research: 'Research' }; 76 const tabBar = document.createElement('div'); 77 tabBar.className = 'compare-mode-tabs'; 78 tabBar.style.marginBottom = '12px'; 79 let activeMode = 'chat'; 80
81 function renderScoreTable() { 82 // Clear previous table 83 const prev = body.querySelector('.scoreboard-wrap'); 84 if (prev) { 85 // The Clear button was moved INTO the wrap on a prior render â rescue it 86 // back to the body before removing the wrap, otherwise it's destroyed 87 // with the wrap and never re-found (it vanished after visiting an empty 88 // mode like Images and switching back). 89 const clr = prev.querySelector('.scoreboard-clear-btn'); 90 if (clr) body.appendChild(clr); 91 prev.remove(); 92 } 93 94 const wrap = document.createElement('div'); 95 wrap.className = 'scoreboard-wrap'; 96 97 const filtered = votes.filter(v => _guessVoteMode(v) === activeMode); 98 99 // Aggregate 100 const stats = {}; 101 for (const v of filtered) { 102 for (let mi = 0; mi < v.models.length; mi++) { 103 const m = v.models[mi]; 104 if (!stats[m]) stats[m] = { wins: 0, losses: 0, ties: 0, games: 0, totalCost: 0, costCount: 0 }; 105 stats[m].games++; 106 if (v.winner === 'tie') stats[m].ties++; 107 else if (v.winner === m) stats[m].wins++; 108 else stats[m].losses++; 109 if (v.costs && v.costs[mi] != null) { 110 stats[m].totalCost += v.costs[mi]; 111 stats[m].costCount++; 112 } 113 } 114 } 115 const sorted = Object.entries(stats).sort((a, b) => { 116 const rateA = a[1].games ? a[1].wins / a[1].games : 0; 117 const rateB = b[1].games ? b[1].wins / b[1].games : 0; 118 return rateB - rateA; 119 }); 120 121 if (sorted.length === 0) { 122 const empty = document.createElement('p'); 123 empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;'; 124 empty.textContent = 'No ' + activeMode + ' votes yet. Run a comparison and vote!'; 125 wrap.appendChild(empty); 126 } else { 127 const table = document.createElement('table'); 128 table.className = 'scoreboard-table'; 129 const thead = document.createElement('thead'); 130 thead.innerHTML = '<tr><th>Model</th><th>Win%</th><th>W</th><th>L</th><th>T</th><th>Games</th><th>$/1k</th></tr>'; 131 table.appendChild(thead); 132 const tbody = document.createElement('tbody'); 133 for (const [name, s] of sorted) { 134 const pct = s.games ? Math.round((s.wins / s.games) * 100) : 0; 135 const avgCost = s.costCount ? (s.totalCost / s.costCount) * 1000 : null; 136 const costStr = avgCost !== null ? ('$' + (avgCost < 1 ? avgCost.toFixed(2) : avgCost.toFixed(0))) : 'â'; 137 const tr = document.createElement('tr'); 138 tr.innerHTML = 139 '<td class="scoreboard-model">' + escapeHtml(name) + '</td>' + 140 '<td class="scoreboard-pct"><strong>' + pct + '%</strong></td>' + 141 '<td>' + s.wins + '</td><td>' + s.losses + '</td><td>' + s.ties + '</td>' + 142 '<td>' + s.games + '</td>' + 143 '<td style="color:var(--color-success, #4caf50);" title="Avg estimated cost per 1,000 responses">' + costStr + '</td>'; 144 tbody.appendChild(tr); 145 } 146 table.appendChild(tbody); 147 wrap.appendChild(table); 148 } 149 150 const total = document.createElement('div'); 151 total.style.cssText = 'font-size:0.8em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:12px;text-align:center;'; 152 total.textContent = filtered.length + ' vote' + (filtered.length !== 1 ? 's' : '') + ' recorded'; 153 wrap.appendChild(total); 154 155 // Move clear button into wrap so it stays at bottom 156 const existingClear = body.querySelector('.scoreboard-clear-btn'); 157 if (existingClear) wrap.appendChild(existingClear); 158 159 body.appendChild(wrap); 160 } 161 162 modes.forEach(mode => { 163 const tab = document.createElement('button'); 164 tab.type = 'button'; 165 tab.className = 'compare-mode-tab' + (mode === activeMode ? ' active' : ''); 166 tab.innerHTML = (_TYPE_ICONS[mode] || '') + '<span class="compare-toggle-label">' + modeLabels[mode] + '</span>'; 167 tab.addEventListener('click', () => { 168 activeMode = mode; 169 tabBar.querySelectorAll('.compare-mode-tab').forEach(t => t.classList.remove('active')); 170 tab.classList.add('active'); 171 renderScoreTable(); 172 }); 173 tabBar.appendChild(tab); 174 }); 175 body.appendChild(tabBar); 176 renderScoreTable(); 177 178 // Clear history button 179 const clearBtn = document.createElement('button'); 180 clearBtn.className = 'scoreboard-clear-btn'; 181 clearBtn.textContent = 'Clear History'; 182 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;'; 183 clearBtn.addEventListener('mouseenter', () => { clearBtn.style.opacity = '1'; }); 184 clearBtn.addEventListener('mouseleave', () => { clearBtn.style.opacity = '0.6'; }); 185 clearBtn.addEventListener('click', () => { 186 // Inline confirmation 187 const confirmRow = document.createElement('div'); 188 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);'; 189 const confirmLabel = document.createElement('span'); 190 confirmLabel.style.cssText = 'font-size:12px;opacity:0.7;'; 191 confirmLabel.textContent = 'Clear all vote history?'; 192 const yesBtn = document.createElement('button'); 193 yesBtn.textContent = 'Clear'; 194 yesBtn.style.cssText = 'padding:4px 12px;background:var(--red);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;'; 195 yesBtn.addEventListener('click', () => { 196 Storage.setJSON(VOTES_STORAGE_KEY, []); 197 overlay.remove(); 198 showScoreboard(); 199 }); 200 const noBtn = document.createElement('button'); 201 noBtn.textContent = 'Cancel'; 202 noBtn.className = 'cmp-btn-secondary'; 203 noBtn.style.cssText = 'padding:4px 12px;border-radius:4px;font-size:12px;'; 204 noBtn.addEventListener('click', () => confirmRow.remove()); 205 confirmRow.appendChild(confirmLabel); 206 confirmRow.appendChild(yesBtn); 207 confirmRow.appendChild(noBtn); 208 // Replace button with confirmation 209 clearBtn.style.display = 'none'; 210 clearBtn.parentElement.appendChild(confirmRow); 211 }); 212 body.appendChild(clearBtn); 213 214 content.appendChild(body); 215 overlay.appendChild(content); 216 document.body.appendChild(overlay); 217 218 if (themeModule && themeModule.makeDraggable) { 219 themeModule.makeDraggable(content, header); 220 } 221} 222 223export default { showScoreboard };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.