1/* public/js/quiz-interactive.js â interactive quiz form handling. 2 * 3 * Auto-grades [quiz_interactive] forms on the public site and records each 4 * attempt in localStorage under the "quiz_scores" key. Never transmitted 5 * anywhere â the visitor's scores live on their own device only. 6 * 7 * Storage schema (localStorage.quiz_scores, JSON): 8 * { 9 * "<category>|<round>": { 10 * title: "...", // last-known page title for display 11 * path: "/...", // page URL so scoreboard can link back 12 * attempts: [ { score, total, ts }, ... ] // newest last, capped at 20 13 * }, 14 * ... 15 * } 16 * 17 * Also powers the [my_scores] scoreboard shortcode mount. 18 */ 19(function () { 20 'use strict'; 21 22 var STORAGE_KEY = 'quiz_scores'; 23 var MAX_ATTEMPTS_PER_ROUND = 20; 24 25 // ---- Storage helpers -------------------------------------------------- 26 function readStore() { 27 try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } 28 catch (e) { return {}; } 29 } 30 function writeStore(data) { 31 try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) {} 32 } 33 function keyFor(category, round) { return category + '|' + round; } 34 35 function recordAttempt(category, round, score, total, pageTitle, pagePath) { 36 var store = readStore(); 37 var k = keyFor(category, round); 38 if (!store[k]) store[k] = { title: pageTitle, path: pagePath, attempts: [] }; 39 store[k].title = pageTitle; store[k].path = pagePath; 40 store[k].attempts.push({ score: score, total: total, ts: Date.now() }); 41 if (store[k].attempts.length > MAX_ATTEMPTS_PER_ROUND) { 42 store[k].attempts.splice(0, store[k].attempts.length - MAX_ATTEMPTS_PER_ROUND); 43 } 44 writeStore(store); 45 } 46 47 function historyFor(category, round) { 48 var store = readStore(); 49 return (store[keyFor(category, round)] || { attempts: [] }).attempts || []; 50 } 51 52 // ---- Form wiring ------------------------------------------------------ 53 function initForm(form) { 54 if (form.dataset.quizInitialised === '1') return; 55 form.dataset.quizInitialised = '1'; 56 57 var category = form.getAttribute('data-quiz-category') || ''; 58 var round = parseInt(form.getAttribute('data-quiz-round'), 10) || 0; 59 var total = parseInt(form.getAttribute('data-quiz-total'), 10) || 0; 60 var items = Array.prototype.slice.call(form.querySelectorAll('.qi-item')); 61 62 // Shuffle each multiple-choice question's options so the correct answer 63 // is not always in the same position. Grading compares the chosen 64 // radio's value to data-qi-correct (both are stable indices), so 65 // reordering the labels in the DOM is safe. Runs on every page load, 66 // so reloading re-shuffles the order. 67 items.forEach(function (li) { 68 if (li.getAttribute('data-qi-type') !== 'multiple_choice') return; 69 var opts = li.querySelector('.qi-opts'); 70 if (!opts) return; 71 var labels = Array.prototype.slice.call(opts.querySelectorAll('.qi-opt')); 72 for (var i = labels.length - 1; i > 0; i--) { 73 var j = Math.floor(Math.random() * (i + 1)); 74 var tmp = labels[i]; labels[i] = labels[j]; labels[j] = tmp; 75 } 76 labels.forEach(function (el) { opts.appendChild(el); }); 77 }); 78 79 var submitBtn = form.querySelector('.qi-submit'); 80 var resetBtn = form.querySelector('.qi-reset'); 81 var result = form.querySelector('.qi-result'); 82 var history = form.querySelector('[data-qi-history]'); 83 84 renderHistory(); 85 86 form.addEventListener('submit', function (e) { 87 e.preventDefault(); 88 gradeAndReveal(); 89 }); 90 91 resetBtn.addEventListener('click', function () { 92 items.forEach(function (li) { 93 li.classList.remove('qi-correct', 'qi-wrong', 'qi-answered'); 94 var fb = li.querySelector('.qi-feedback'); 95 if (fb) fb.setAttribute('hidden', ''); 96 li.querySelectorAll('input[type="radio"]').forEach(function (r) { 97 r.checked = false;
98 r.disabled = false; 99 }); 100 }); 101 result.setAttribute('hidden', ''); 102 result.innerHTML = ''; 103 resetBtn.setAttribute('hidden', ''); 104 submitBtn.disabled = false; 105 submitBtn.textContent = 'Check my answers'; 106 form.scrollIntoView({ behavior: 'smooth', block: 'start' }); 107 }); 108 109 function gradeAndReveal() { 110 var correct = 0; 111 items.forEach(function (li) { 112 var expected = li.getAttribute('data-qi-correct'); 113 var chosen = null; 114 li.querySelectorAll('input[type="radio"]').forEach(function (r) { 115 r.disabled = true; 116 if (r.checked) chosen = r.value; 117 }); 118 li.classList.add('qi-answered'); 119 var fb = li.querySelector('.qi-feedback'); 120 var fbLabel = fb ? fb.querySelector('.qi-feedback-label') : null; 121 if (chosen !== null && chosen === expected) { 122 li.classList.add('qi-correct'); 123 correct++; 124 if (fbLabel) fbLabel.textContent = 'Correct â'; 125 } else { 126 li.classList.add('qi-wrong'); 127 if (fbLabel) fbLabel.textContent = chosen === null ? 'Skipped.' : 'Not quite.'; 128 } 129 if (fb) fb.removeAttribute('hidden'); 130 }); 131 132 var pct = total > 0 ? Math.round((correct / total) * 100) : 0; 133 var msg = formatResultMessage(correct, total, pct); 134 135 result.innerHTML = 136 '<div class="qi-score"><strong>' + correct + ' / ' + total + '</strong> <span class="qi-pct">(' + pct + '%)</span></div>' + 137 '<p class="qi-score-msg">' + msg + '</p>' + 138 '<p class="qi-score-cta"><small>Scroll down for the explanations, or use the links at the top to see the full answer page.</small></p>'; 139 result.removeAttribute('hidden'); 140 141 submitBtn.disabled = true; 142 submitBtn.textContent = 'Submitted'; 143 resetBtn.removeAttribute('hidden'); 144 145 recordAttempt(category, round, correct, total, document.title, location.pathname); 146 renderHistory(); 147 148 result.scrollIntoView({ behavior: 'smooth', block: 'center' }); 149 } 150 151 function renderHistory() { 152 if (!history) return; 153 var attempts = historyFor(category, round); 154 if (!attempts.length) { history.setAttribute('hidden', ''); history.innerHTML = ''; return; } 155 var best = attempts.reduce(function (a, b) { return b.score > a.score ? b : a; }); 156 var last = attempts[attempts.length - 1]; 157 history.innerHTML = 158 '<span class="qi-history-label">Your history for this round:</span> ' + 159 '<span class="qi-history-stat">Best <strong>' + best.score + '/' + best.total + '</strong></span>' + 160 '<span class="qi-history-stat">Last <strong>' + last.score + '/' + last.total + '</strong></span>' + 161 '<span class="qi-history-stat">Attempts <strong>' + attempts.length + '</strong></span>'; 162 history.removeAttribute('hidden'); 163 } 164 } 165 166 function formatResultMessage(correct, total, pct) { 167 if (total === 0) return ''; 168 if (pct === 100) return 'Perfect! A clean sweep â every answer right.'; 169 if (pct >= 80) return 'Great work. Most of them correct.'; 170 if (pct >= 50) return 'A solid effort. Try again to beat your score.'; 171 if (pct > 0) return 'A few right â have another go.'; 172 return 'Tough round. Review the answers below and try again.'; 173 } 174 175 // ---- Scoreboard shortcode mount --------------------------------------- 176 // Keys in the shared `quiz_scores` localStorage object are grouped by 177 // tool via a leading prefix. Each tool has its own column layout in the 178 // scoreboard because their attempt schemas differ: 179 // quiz (no prefix) â { score, total, ts } 180 // xw: crossword â { time, ts } 181 // ws: word search â { time, ts } 182 // sd: sudoku â { time, ts, difficulty? } 183 // rd: riddles â { score, total, tokens, of, ts } 184 // cr: cryptograms â { score, total, tokens, of, ts } 185 // mp: memory pairs â { score, total, moves, time, ts } 186 // an: anagrams â { score, total, tokens, of, ts } 187 // The prefixless group is the catch-all for keys that don't match any 188 // known tool prefix, which is where original quiz rounds live. 189 190 var TOOL_DEFS = [ 191 { key: 'quizzes', label: 'Quizzes', prefix: null, layout: 'quiz', emptyLabel: 'quiz scores' }, 192 { key: 'crosswords', label: 'Crosswords', prefix: 'xw:', layout: 'time', emptyLabel: 'crossword completions' }, 193 { key: 'wordSearches', label: 'Word Searches', prefix: 'ws:', layout: 'time', emptyLabel: 'word search completions' }, 194 { key: 'sudoku', label: 'Sudoku', prefix: 's
194d:', layout: 'time', emptyLabel: 'sudoku completions' }, 195 { key: 'riddles', label: 'Riddles', prefix: 'rd:', layout: 'tokenScore', emptyLabel: 'riddle scores' }, 196 { key: 'cryptograms', label: 'Cryptograms', prefix: 'cr:', layout: 'tokenScore', emptyLabel: 'cryptogram scores' }, 197 { key: 'memoryPairs', label: 'Memory Pairs', prefix: 'mp:', layout: 'memoryPairs', emptyLabel: 'memory game completions' }, 198 { key: 'anagrams', label: 'Anagrams', prefix: 'an:', layout: 'tokenScore', emptyLabel: 'anagram scores' }, 199 ]; 200 201 function initScoreboard(mount) { 202 if (mount.dataset.scoresInitialised === '1') return; 203 mount.dataset.scoresInitialised = '1'; 204 var activeTab = null; // set to first non-empty tool on first render 205 render(); 206 207 function render() { 208 var store = readStore(); 209 var keys = Object.keys(store); 210 211 if (!keys.length) { 212 mount.innerHTML = 213 '<div class="my-scores-empty">' + 214 '<h2>No scores yet</h2>' + 215 '<p>Play any puzzle or quiz on this site and your results will show up here. Everything is stored on your own device. Nothing is sent anywhere.</p>' + 216 '</div>'; 217 return; 218 } 219 220 // Group keys by tool prefix. Keys not matching any prefix fall 221 // through to 'quizzes'. 222 var groups = {}; 223 TOOL_DEFS.forEach(function (t) { groups[t.key] = []; }); 224 keys.forEach(function (k) { 225 var matched = false; 226 for (var i = 0; i < TOOL_DEFS.length; i++) { 227 var t = TOOL_DEFS[i]; 228 if (t.prefix && k.indexOf(t.prefix) === 0) { 229 groups[t.key].push(k); 230 matched = true; 231 break; 232 } 233 } 234 if (!matched) groups.quizzes.push(k); 235 }); 236 237 // Sort each group: most-recent attempt first 238 Object.keys(groups).forEach(function (gk) { 239 groups[gk].sort(function (a, b) { 240 var la = (store[a] && store[a].attempts) || []; 241 var lb = (store[b] && store[b].attempts) || []; 242 var ta = la.length ? la[la.length - 1].ts : 0; 243 var tb = lb.length ? lb[lb.length - 1].ts : 0; 244 return tb - ta; 245 }); 246 }); 247 248 // Pick a sensible default tab if this is the first render or the 249 // previously-active tab is now empty. 250 if (activeTab == null || !groups[activeTab] || !groups[activeTab].length) { 251 activeTab = null; 252 for (var j = 0; j < TOOL_DEFS.length; j++) { 253 if (groups[TOOL_DEFS[j].key].length) { activeTab = TOOL_DEFS[j].key; break; } 254 } 255 if (activeTab == null) activeTab = 'quizzes'; 256 } 257 258 // Tab bar: only show tools that have at least one entry 259 var visibleTools = TOOL_DEFS.filter(function (t) { return groups[t.key].length > 0; }); 260 var tabsHtml = '<div class="my-scores-tabs">' + 261 visibleTools.map(function (t) { 262 return '<button type="button" class="my-scores-tab' + 263 (activeTab === t.key ? ' is-active' : '') + 264 '" data-tab="' + t.key + '">' + 265 escapeHtml(t.label) + ' <span>' + groups[t.key].length + '</span></button>'; 266 }).join('') + 267 '</div>'; 268 269 var active = TOOL_DEFS.filter(function (t) { return t.key === activeTab; })[0] || TOOL_DEFS[0]; 270 var activeKeys = groups[active.key] || []; 271 var listHtml = activeKeys.length 272 ? '<ul class="my-scores-list">' + activeKeys.map(function (k) { 273 var entry = store[k]; 274 var attempts = (entry && entry.attempts) || []; 275 if (!attempts.length) return ''; 276 return renderRow(active.layout, entry, attempts); 277 }).join('') + '</ul>' 278 : '<div class="my-scores-empty-tab">No ' + escapeHtml(active.emptyLabel) + ' yet.</div>'; 279 280 mount.innerHTML = 281 '<div class="my-scores-head">' + 282 '<h2>Your scores</h2>' + 283 '<button type="button" class="my-scores-clear">Clear all scores</button>' + 284 '</div>' + 285 '<p class="my-scores-note">Stored on this device only. Clearing your browser data (or clicking the button above) will erase them.</p>' + 286 tabsHtml + listHtml; 287 288 mount.querySelectorAll('.my-scores-tab').forEach(function (btn) { 289 btn.addEventListener('click', function () { 290 activeTab = btn.getAttribute('data-tab'); 291 render(); 292 }); 293 }); 294 var clear = mount.querySelector('.my-scores-clear'); 295 if (clear) clear.addEventListener('click', function () { 296 if (!confirm('Erase every saved score and puzzle progress on this device? This cannot be undone.')) return; 297 var keys = [ 298 STORAGE_KEY, 299 'crossword_progress', 'word_search_progress', 'sudoku_progress', 300 'riddle_progress', 'cryptogram_progress', 'memory_pairs_progress', 301 'anagram_progress', 302 ]; 303 keys.forEach(function (k) { try { localStorage.removeItem(k); } catch (e) {} }); 304 activeTab = null; 305 render(); 306 }); 307 } 308 309 // Per-layout row renderer. `layout` picks columns appropriate to the 310 // attempt schema; the HTML shell (li + title + stats row) is shared. 311 function renderRow(layout, entry, attempts) { 312 var last = attempts[attempts.length - 1]; 313 var title = '<a class="my-scores-title" href="' + escapeAttr(entry.path || '#') + '">' + 314 escapeHtml(entry.title || '(untitled)') + '</a>'; 315 var stats = []; 316 317 if (layout === 'quiz') { 318 var bestQ = attempts.reduce(function (a, b) { return b.score > a.score ? b : a; }); 319 stats.push('Best <strong>' + bestQ.score + '/' + bestQ.total + '</strong>'); 320 stats.push('Last <strong>' + last.score + '/' + last.total + '</strong> · ' + timeAgo(last.ts)); 321 stats.push('Attempts <strong>' + attempts.length + '</strong>'); 322 323 } else if (layout === 'time') { 324 // Fastest completion across timed attempts 325 var timed = attempts.filter(function (a) { return typeof a.time === 'number' && a.time > 0; }); 326 var bestTime = timed.length ? timed.reduce(function (a, b) { return b.time < a.time ? b : a; }).time : null; 327 if (bestTime != null) stats.push('Best time <strong>' + formatTime(bestTime) + '</strong>'); 328 stats.push('Last <strong>' + (last.time ? formatTime(last.time) : '-') + '</strong> · ' + timeAgo(last.ts)); 329 stats.push('Completions <strong>' + attempts.length + '</strong>'); 330 331 } else if (layout === 'tokenScore') { 332 // Best = highest score; ties broken by tokens remaining 333 var bestT = attempts.reduce(function (a, b) { 334 if (b.score > a.score) return b; 335 if (b.score === a.score && ((b.tokens || 0) > (a.tokens || 0))) return b; 336 return a; 337 }); 338 var tokenStr = (typeof bestT.tokens === 'number' && typeof bestT.of === 'number') 339 ? ' <small>(' + bestT.tokens + '/' + bestT.of + ' tokens left)</small>' 340 : ''; 341 stats.push('Best <strong>' + bestT.score + '/' + bestT.total + '</strong>' + tokenStr); 342 stats.push('Last <strong>' + last.score + '/' + last.total + '</strong> · ' + timeAgo(last.ts)); 343 stats.push('Attempts <strong>' + attempts.length + '</strong>'); 344 345 } else if (layout === 'memoryPairs') { 346 var timed2 = attempts.filter(function (a) { return typeof a.time === 'number' && a.time > 0; }); 347 var bestT2 = timed2.length ? timed2.reduce(function (a, b) { return b.time < a.time ? b : a; }).time : null; 348 var moved = attempts.filter(function (a) { return typeof a.moves === 'number' && a.moves > 0; }); 349 var bestM = moved.length ? moved.reduce(function (a, b) { return b.moves < a.moves ? b : a; }).moves : null; 350 if (bestT2 != null) stats.push('Best time <strong>' + formatTime(bestT2) + '</strong>'); 351 if (bestM != null) stats.push('Best moves <strong>' + bestM + '</strong>'); 352 var lastStr = (last.time ? formatTime(last.time) : '-') + 353 (last.moves ? ' · ' + last.moves + ' moves' : ''); 354 stats.push('Last <strong>' + lastStr + '</strong> · ' + timeAgo(last.ts)); 355 stats.push('Completions <strong>' + attempts.length + '</strong>'); 356 } 357 358 return '<li class="my-scores-row">' + 359 title + 360 '<div class="my-scores-stats">' + 361 stats.map(function (s) { return '<span>' + s + '</span>'; }).join('') + 362 '</div>' + 363 '</li>'; 364 } 365 } 366 367 function formatTime(s) { 368 s = Math.max(0, parseInt(s, 10) || 0); 369 var m = Math.floor(s / 60), ss = s % 60; 370 return m + ':' + String(ss).padStart(2, '0'); 371 } 372 373 function timeAgo(ts) { 374 var s = Math.max(0, Math.floor((Date.now() - ts) / 1000)); 375 if (s < 60) return 'just now'; 376 if (s < 3600) return Math.floor(s / 60) + ' min ago'; 377 if (s < 86400) return Math.floor(s / 3600) + ' h ago'; 378 if (s < 604800) return Math.floor(s / 86400) + ' days ago'; 379 return new Date(ts).toLocaleDateString(); 380 } 381 function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]; }); } 382 function escapeAttr(s) { return escapeHtml(s); } 383 384 function boot() { 385 document.querySelectorAll('form.quiz-interactive').forEach(initForm); 386 document.querySelectorAll('[data-my-scores-mount]').forEach(initScoreboard); 387 } 388 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); 389 else boot(); 390})();
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.