1// Gov Arena â Vote Store & Elo Engine 2// localStorage-backed, designed for future Supabase migration 3 4window.VoteStore = (function() { 5 'use strict'; 6 7 var VOTES_KEY = 'gov-arena-votes'; 8 var ELO_KEY = 'gov-arena-elo'; 9 var DEFAULT_ELO = 1200; 10 var K_FACTOR = 32; 11 var MIN_VOTES_THRESHOLD = 5; 12 13 // ==================== STORAGE ==================== 14 15 function loadVotes() { 16 try { 17 return JSON.parse(localStorage.getItem(VOTES_KEY)) || []; 18 } catch(e) { 19 return []; 20 } 21 } 22 23 function saveVotes(votes) { 24 localStorage.setItem(VOTES_KEY, JSON.stringify(votes)); 25 } 26 27 function loadElo() { 28 try { 29 return JSON.parse(localStorage.getItem(ELO_KEY)) || {}; 30 } catch(e) { 31 return {}; 32 } 33 } 34 35 function saveElo(elo) { 36 localStorage.setItem(ELO_KEY, JSON.stringify(elo)); 37 } 38 39 // ==================== ELO CALCULATION ==================== 40 41 function computeEloDelta(ratingA, ratingB, scoreA, kOverride) { 42 // scoreA: 1 = A wins, 0 = A loses, 0.5 = tie 43 var k = kOverride || K_FACTOR; 44 var expectedA = 1.0 / (1.0 + Math.pow(10, (ratingB - ratingA) / 400)); 45 return Math.round(k * (scoreA - expectedA)); 46 } 47 48 function getModelCatRecord(elo, modelName, catId) { 49 if (!elo[modelName]) elo[modelName] = {}; 50 if (!elo[modelName][catId]) elo[modelName][catId] = { rating: DEFAULT_ELO, wins: 0, losses: 0, ties: 0 }; 51 return elo[modelName][catId]; 52 } 53 54 // ==================== PUBLIC API ==================== 55 56 function recordVote(voteRecord) { 57 var votes = loadVotes(); 58 var elo = loadElo(); 59 60 // Generate vote ID 61 var id = 'v_' + Date.now() + '_' + Math.random().toString(16).slice(2, 6); 62 voteRecord.id = id; 63 voteRecord.timestamp = new Date().toISOString(); 64 65 // Truncate question for storage 66 if (voteRecord.question && voteRecord.question.length > 120) { 67 voteRecord.question = voteRecord.question.slice(0, 120) + '...'; 68 } 69 70 // Compute Elo deltas (skip if flagged as speed-click) 71 var deltaA = 0; 72 var deltaB = 0; 73 74 if (!voteRecord.flagged) { 75 var catId = voteRecord.categoryId; 76 var recA = getModelCatRecord(elo, voteRecord.modelA, catId); 77 var recB = getModelCatRecord(elo, voteRecord.modelB, catId); 78 79 var scoreA; 80 var kOverride = null; 81 if (voteRecord.winner === 'a') { 82 scoreA = 1; 83 recA.wins++; 84 recB.losses++; 85 } else if (voteRecord.winner === 'b') { 86 scoreA = 0; 87 recA.losses++; 88 recB.wins++; 89 } else if (voteRecord.winner === 'both-good' || voteRecord.winner === 'both-bad') { 90 scoreA = 0.5; 91 recA.ties++; 92 recB.ties++; 93 kOverride = K_FACTOR / 2; // Half impact for both-good/both-bad 94 } else { 95 scoreA = 0.5; 96 recA.ties++; 97 recB.ties++; 98 } 99 100 deltaA = computeEloDelta(recA.rating, recB.rating, scoreA, kOverride); 101 deltaB = -deltaA; 102 103 recA.rating += deltaA; 104 recB.rating += deltaB; 105 106 saveElo(elo); 107 } 108 109 voteRecord.eloDeltaA = deltaA; 110 voteRecord.eloDeltaB = deltaB; 111 112 votes.push(voteRecord); 113 saveVotes(votes); 114 115 return { deltaA: deltaA, deltaB: deltaB }; 116 } 117 118 function getElo(modelName, categoryId) { 119 var elo = loadElo(); 120 if (elo[modelName] && elo[modelName][categoryId]) { 121 return elo[modelName][categoryId].rating; 122 } 123 return DEFAULT_ELO; 124 } 125 126 function getOverallElo(modelName) { 127 var elo = loadElo(); 128 if (!elo[modelName]) return DEFAULT_ELO; 129 130 var cats = Object.keys(elo[modelName]); 131 if (cats.length === 0) return DEFAULT_ELO; 132 133 var sum = 0; 134 for (var i = 0; i < cats.length; i++) { 135 sum += elo[modelName][cats[i]].rating; 136 } 137 // For categories without votes, use default Elo 138 var allCatCount = ALL_CATS.length; 139 var votedSum = sum; 140 var unvotedCount = allCatCount - cats.length; 141 var total = votedSum + (unvotedCount * DEFAULT_ELO); 142 return Math.round(total / allCatCount); 143 } 144 145 function getVoteCount(modelName, categoryId) { 146 var elo = loadElo(); 147 if (elo[modelName] && elo[modelName][categoryId]) { 148 var rec = elo[modelName][categoryId]; 149 return rec.wins + rec.losses + rec.ties; 150 } 151 return 0; 152 } 153 154 function getTotalVoteCount(modelName) { 155 var elo = loadElo(); 156 if (!elo[modelName]) return 0; 157 var total = 0; 158 var cats = Object.keys(elo[modelName]); 159 for (var i = 0; i < cats.length; i++) { 160 var rec = elo[modelName][cats[i]]; 161 total += rec.wins + rec.losses + rec.ties; 162 } 163 return total; 164 } 165 166 function getLeaderboard(catId) { 167 if (catId === 'overall') { 168 return SAMPLE_MODELS.map(function(m) { 169 return { 170 name: m.name, 171 short: m.short, 172 elo: getOverallElo(m.name), 173 provider: m.provider, 174 org: m.org, 175 license: m.license, 176 votes: getTotalVoteCount(m.name), 177 provisional: getTotalVoteCount(m.name) < MIN_VOTES_THRESHOLD 178 }; 179 }).sort(function(a, b) { return b.elo - a.elo; }); 180 } 181 182 return SAMPLE_MODELS.map(function(m) { 183 return { 184 name: m.name, 185 short: m.short, 186 elo: getElo(m.name, catId), 187 provider: m.provider, 188 org: m.org, 189 license: m.license, 190 votes: getVoteCount(m.name, catId), 191 provisional: getVoteCount(m.name, catId) < MIN_VOTES_THRESHOLD 192 }; 193 }).sort(function(a, b) { return b.elo - a.elo; }); 194 } 195 196 function hasVotes() { 197 var votes = loadVotes(); 198 return votes.length > 0; 199 } 200 201 function totalVotes() { 202 return loadVotes().length; 203 } 204 205 function reset() { 206 localStorage.removeItem(VOTES_KEY); 207 localStorage.removeItem(ELO_KEY); 208 } 209 210 return { 211 recordVote: recordVote, 212 getElo: getElo, 213 getOverallElo: getOverallElo, 214 getVoteCount: getVoteCount, 215 getTotalVoteCount: getTotalVoteCount, 216 getLeaderboard: getLeaderboard, 217 hasVotes: hasVotes, 218 totalVotes: totalVotes, 219 reset: reset, 220 MIN_VOTES_THRESHOLD: MIN_VOTES_THRESHOLD, 221 }; 222})();
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.