PageSourceSearch

https://www.knowalot.org/js/quiz-interactive.js?v=202606082152

js knowalot.org collected 2026-10-08 20:49:33 UTC 20,186 bytes, 390 lines download raw bytes

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> &middot; ' + 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> &middot; ' + 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> &middot; ' + 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 ? ' &middot; ' + last.moves + ' moves' : '');
354                stats.push('Last <strong>' + lastStr + '</strong> &middot; ' + 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 ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[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.