Files
TaroTime/app/ui-quiz.js

644 lines
18 KiB
JavaScript
Raw Permalink Normal View History

2026-03-07 01:09:00 -08:00
/* ui-quiz.js — Correspondences quiz */
(function () {
"use strict";
2026-03-08 22:24:34 -07:00
const dataService = window.TarotDataService || {};
2026-03-07 01:09:00 -08:00
const state = {
initialized: false,
scoreCorrect: 0,
scoreAnswered: 0,
selectedCategory: "random",
selectedDifficulty: "normal",
questionBank: [],
templateByKey: new Map(),
runUnseenKeys: [],
runRetryKeys: [],
runRetrySet: new Set(),
currentQuestion: null,
answeredCurrent: false,
2026-03-08 22:24:34 -07:00
loadingQuestion: false,
2026-03-07 01:09:00 -08:00
autoAdvanceTimer: null,
autoAdvanceDelayMs: 1500
};
const FIXED_CATEGORY_OPTIONS = [
{ value: "random", label: "Random" },
{ value: "all", label: "All" }
];
const CATEGORY_META = [
{ id: "english-gematria", label: "English Gematria" },
{ id: "hebrew-numerology", label: "Hebrew Gematria" },
{ id: "english-hebrew-mapping", label: "Alphabet Mapping" },
{ id: "zodiac-rulers", label: "Zodiac Rulers" },
{ id: "zodiac-elements", label: "Zodiac Elements" },
{ id: "planetary-weekdays", label: "Planetary Weekdays" },
{ id: "zodiac-tarot", label: "Zodiac ↔ Tarot" },
{ id: "kabbalah-path-between-sephirot", label: "Kabbalah: Path Between Sephirot" },
{ id: "kabbalah-path-letter", label: "Kabbalah: Path Number ↔ Letter" },
{ id: "kabbalah-path-tarot", label: "Kabbalah: Path ↔ Tarot Trump" },
{ id: "sephirot-planets", label: "Sephirot ↔ Planet" },
{ id: "tarot-decan-sign", label: "Tarot Decans: Card ↔ Decan" },
{ id: "tarot-decan-ruler", label: "Tarot Decans: Card ↔ Ruler" },
{ id: "tarot-cube-location", label: "Tarot ↔ Cube Location" },
{ id: "cube-hebrew-letter", label: "Cube ↔ Hebrew Letter" },
{ id: "playing-card-tarot", label: "Playing Card ↔ Tarot" }
];
// Dynamic category plugin registry — populated by registerQuizCategory()
const DYNAMIC_CATEGORY_REGISTRY = [];
2026-03-07 05:17:50 -08:00
const quizQuestionBank = window.QuizQuestionBank || {};
2026-03-07 01:09:00 -08:00
function registerQuizCategory(id, label, builder) {
if (typeof id !== "string" || !id || typeof builder !== "function") {
return;
}
const existingIndex = DYNAMIC_CATEGORY_REGISTRY.findIndex((entry) => entry.id === id);
if (existingIndex >= 0) {
DYNAMIC_CATEGORY_REGISTRY[existingIndex] = { id, label: String(label || id), builder };
} else {
DYNAMIC_CATEGORY_REGISTRY.push({ id, label: String(label || id), builder });
}
if (!CATEGORY_META.some((item) => item.id === id)) {
CATEGORY_META.push({ id, label: String(label || id) });
}
}
let categoryEl;
let difficultyEl;
let questionTypeEl;
let questionEl;
let optionsEl;
let feedbackEl;
let resetEl;
let scoreCorrectEl;
let scoreAnsweredEl;
let scoreAccuracyEl;
function getElements() {
categoryEl = document.getElementById("quiz-category");
difficultyEl = document.getElementById("quiz-difficulty");
questionTypeEl = document.getElementById("quiz-question-type");
questionEl = document.getElementById("quiz-question");
optionsEl = document.getElementById("quiz-options");
feedbackEl = document.getElementById("quiz-feedback");
resetEl = document.getElementById("quiz-reset");
scoreCorrectEl = document.getElementById("quiz-score-correct");
scoreAnsweredEl = document.getElementById("quiz-score-answered");
scoreAccuracyEl = document.getElementById("quiz-score-accuracy");
}
function isReady() {
return Boolean(
categoryEl
&& difficultyEl
&& questionTypeEl
&& questionEl
&& optionsEl
&& feedbackEl
&& resetEl
&& scoreCorrectEl
&& scoreAnsweredEl
&& scoreAccuracyEl
);
}
function clearAutoAdvanceTimer() {
if (!state.autoAdvanceTimer) {
return;
}
clearTimeout(state.autoAdvanceTimer);
state.autoAdvanceTimer = null;
}
function queueAutoAdvance() {
clearAutoAdvanceTimer();
state.autoAdvanceTimer = setTimeout(() => {
state.autoAdvanceTimer = null;
showNextQuestion();
}, state.autoAdvanceDelayMs);
}
function toTitleCase(value) {
const text = String(value || "").trim().toLowerCase();
if (!text) {
return "";
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
function normalizeOption(value) {
return String(value || "").trim();
}
function normalizeKey(value) {
return normalizeOption(value).toLowerCase();
}
function toUniqueOptionList(values) {
const seen = new Set();
const unique = [];
(values || []).forEach((value) => {
const formatted = normalizeOption(value);
if (!formatted) {
return;
}
const key = normalizeKey(formatted);
if (seen.has(key)) {
return;
}
seen.add(key);
unique.push(formatted);
});
return unique;
}
function shuffle(list) {
const clone = list.slice();
for (let index = clone.length - 1; index > 0; index -= 1) {
const nextIndex = Math.floor(Math.random() * (index + 1));
[clone[index], clone[nextIndex]] = [clone[nextIndex], clone[index]];
}
return clone;
}
function pickMany(list, count) {
if (!Array.isArray(list) || !list.length || count <= 0) {
return [];
}
return shuffle(list).slice(0, Math.min(count, list.length));
}
function getActiveDifficulty() {
const value = String(state.selectedDifficulty || "normal").toLowerCase();
if (value === "easy" || value === "hard") {
return value;
}
return "normal";
}
function resolveDifficultyValue(valueByDifficulty, difficulty = getActiveDifficulty()) {
if (valueByDifficulty == null) {
return "";
}
if (typeof valueByDifficulty !== "object" || Array.isArray(valueByDifficulty)) {
return valueByDifficulty;
}
if (Object.prototype.hasOwnProperty.call(valueByDifficulty, difficulty)) {
return valueByDifficulty[difficulty];
}
if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "normal")) {
return valueByDifficulty.normal;
}
if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "easy")) {
return valueByDifficulty.easy;
}
if (Object.prototype.hasOwnProperty.call(valueByDifficulty, "hard")) {
return valueByDifficulty.hard;
}
return "";
}
function buildOptions(correctValue, poolValues) {
const correct = normalizeOption(correctValue);
if (!correct) {
return null;
}
const uniquePool = toUniqueOptionList(poolValues || []);
if (!uniquePool.some((value) => normalizeKey(value) === normalizeKey(correct))) {
uniquePool.push(correct);
}
const distractors = uniquePool.filter((value) => normalizeKey(value) !== normalizeKey(correct));
if (distractors.length < 3) {
return null;
}
const selectedDistractors = pickMany(distractors, 3);
const options = shuffle([correct, ...selectedDistractors]);
const correctIndex = options.findIndex((value) => normalizeKey(value) === normalizeKey(correct));
if (correctIndex < 0 || options.length < 4) {
return null;
}
return {
options,
correctIndex
};
}
2026-03-08 22:24:34 -07:00
async function buildQuestionBank(referenceData, magickDataset) {
const payload = await dataService.loadQuizTemplates?.();
return Array.isArray(payload?.templates)
? payload.templates
: (Array.isArray(payload) ? payload : []);
2026-03-07 01:09:00 -08:00
}
2026-03-08 22:24:34 -07:00
async function refreshQuestionBank(referenceData, magickDataset) {
state.questionBank = await buildQuestionBank(referenceData, magickDataset);
2026-03-07 01:09:00 -08:00
state.templateByKey = new Map(state.questionBank.map((template) => [template.key, template]));
const hasTemplate = (key) => state.templateByKey.has(key);
state.runUnseenKeys = state.runUnseenKeys.filter(hasTemplate);
state.runRetryKeys = state.runRetryKeys.filter(hasTemplate);
state.runRetrySet = new Set(state.runRetryKeys);
if (state.currentQuestion && !state.templateByKey.has(state.currentQuestion.key)) {
state.currentQuestion = null;
state.answeredCurrent = true;
}
}
function getScopedTemplates() {
if (!state.questionBank.length) {
return [];
}
const mode = state.selectedCategory || "random";
if (mode === "random" || mode === "all") {
return state.questionBank.slice();
}
return state.questionBank.filter((template) => template.categoryId === mode);
}
function getCategoryOptions() {
const availableCategoryIds = new Set(state.questionBank.map((template) => template.categoryId));
2026-03-08 03:52:25 -07:00
const labelByCategoryId = new Map(CATEGORY_META.map((item) => [item.id, item.label]));
state.questionBank.forEach((template) => {
const categoryId = String(template?.categoryId || "").trim();
const category = String(template?.category || "").trim();
if (categoryId && category && !labelByCategoryId.has(categoryId)) {
labelByCategoryId.set(categoryId, category);
}
});
const dynamic = [...availableCategoryIds]
.sort((left, right) => {
const leftLabel = String(labelByCategoryId.get(left) || left);
const rightLabel = String(labelByCategoryId.get(right) || right);
return leftLabel.localeCompare(rightLabel);
})
.map((categoryId) => ({
value: categoryId,
label: String(labelByCategoryId.get(categoryId) || categoryId)
}));
2026-03-07 01:09:00 -08:00
return [...FIXED_CATEGORY_OPTIONS, ...dynamic];
}
function renderCategoryOptions() {
if (!categoryEl) {
return false;
}
const options = getCategoryOptions();
const preservedValue = state.selectedCategory;
categoryEl.innerHTML = "";
options.forEach((optionDef) => {
const option = document.createElement("option");
option.value = optionDef.value;
option.textContent = optionDef.label;
categoryEl.appendChild(option);
});
const validValues = new Set(options.map((optionDef) => optionDef.value));
const nextSelected = validValues.has(preservedValue) ? preservedValue : "random";
state.selectedCategory = nextSelected;
categoryEl.value = nextSelected;
return nextSelected !== preservedValue;
}
function syncDifficultyControl() {
if (!difficultyEl) {
return;
}
const difficulty = getActiveDifficulty();
state.selectedDifficulty = difficulty;
difficultyEl.value = difficulty;
}
function getRunLabel() {
const mode = state.selectedCategory || "random";
if (mode === "random") {
return "Random";
}
if (mode === "all") {
return "All";
}
2026-03-08 03:52:25 -07:00
return CATEGORY_META.find((item) => item.id === mode)?.label
|| state.questionBank.find((template) => template.categoryId === mode)?.category
|| "Category";
2026-03-07 01:09:00 -08:00
}
2026-03-08 22:24:34 -07:00
async function startRun(resetScore = false) {
2026-03-07 01:09:00 -08:00
clearAutoAdvanceTimer();
if (resetScore) {
state.scoreCorrect = 0;
state.scoreAnswered = 0;
}
const templates = getScopedTemplates();
const mode = state.selectedCategory || "random";
if (!templates.length) {
state.runUnseenKeys = [];
state.runRetryKeys = [];
state.runRetrySet = new Set();
state.currentQuestion = null;
state.answeredCurrent = true;
updateScoreboard();
renderNoQuestionState();
return;
}
let orderedTemplates;
if (mode === "all") {
orderedTemplates = templates
.slice()
.sort((left, right) => {
const leftCategory = String(left.category || "");
const rightCategory = String(right.category || "");
if (leftCategory === rightCategory) {
return String(left.key || "").localeCompare(String(right.key || ""));
}
return leftCategory.localeCompare(rightCategory);
});
} else {
orderedTemplates = shuffle(templates);
}
state.runUnseenKeys = orderedTemplates.map((template) => template.key);
state.runRetryKeys = [];
state.runRetrySet = new Set();
state.currentQuestion = null;
state.answeredCurrent = true;
updateScoreboard();
2026-03-08 22:24:34 -07:00
await showNextQuestion();
2026-03-07 01:09:00 -08:00
}
function popNextTemplateFromRun() {
while (state.runUnseenKeys.length) {
const key = state.runUnseenKeys.shift();
const template = state.templateByKey.get(key);
if (template) {
return template;
}
}
while (state.runRetryKeys.length) {
const key = state.runRetryKeys.shift();
state.runRetrySet.delete(key);
const template = state.templateByKey.get(key);
if (template) {
return template;
}
}
return null;
}
function renderRunCompleteState() {
2026-03-08 22:24:34 -07:00
state.loadingQuestion = false;
2026-03-07 01:09:00 -08:00
state.currentQuestion = null;
state.answeredCurrent = true;
questionTypeEl.textContent = getRunLabel();
questionEl.textContent = "Run complete. All previously missed questions are now correct.";
optionsEl.innerHTML = "";
feedbackEl.textContent = "Change category/difficulty or press Reset Score to start a new run.";
}
function updateScoreboard() {
if (!isReady()) {
return;
}
const answered = state.scoreAnswered;
const correct = state.scoreCorrect;
const accuracy = answered > 0
? `${Math.round((correct / answered) * 100)}%`
: "0%";
scoreCorrectEl.textContent = String(correct);
scoreAnsweredEl.textContent = String(answered);
scoreAccuracyEl.textContent = accuracy;
}
function renderQuestion(question) {
if (!isReady()) {
return;
}
2026-03-08 22:24:34 -07:00
state.loadingQuestion = false;
2026-03-07 01:09:00 -08:00
state.currentQuestion = question;
state.answeredCurrent = false;
questionTypeEl.textContent = question.category;
questionEl.textContent = question.prompt;
feedbackEl.textContent = "Choose the best answer.";
optionsEl.innerHTML = "";
question.options.forEach((optionText, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "quiz-option";
button.textContent = `${String.fromCharCode(65 + index)}. ${optionText}`;
button.dataset.optionIndex = String(index);
button.addEventListener("click", () => {
submitAnswer(index);
});
optionsEl.appendChild(button);
});
}
function renderNoQuestionState() {
if (!isReady()) {
return;
}
2026-03-08 22:24:34 -07:00
state.loadingQuestion = false;
2026-03-07 01:09:00 -08:00
state.currentQuestion = null;
state.answeredCurrent = true;
questionTypeEl.textContent = getRunLabel();
questionEl.textContent = "Not enough variation for this category yet.";
optionsEl.innerHTML = "";
feedbackEl.textContent = "Try Random/All or switch to another category.";
}
2026-03-08 22:24:34 -07:00
async function createQuestionFromTemplate(template) {
if (!template) {
return null;
}
return dataService.pullQuizQuestion?.({
templateKey: template.key,
difficulty: getActiveDifficulty(),
includeAnswer: true
});
}
async function showNextQuestion() {
2026-03-07 01:09:00 -08:00
clearAutoAdvanceTimer();
2026-03-08 22:24:34 -07:00
if (state.loadingQuestion) {
return;
}
2026-03-07 01:09:00 -08:00
const totalPending = state.runUnseenKeys.length + state.runRetryKeys.length;
if (totalPending <= 0) {
if (state.questionBank.length) {
renderRunCompleteState();
} else {
renderNoQuestionState();
}
return;
}
const maxAttempts = totalPending + 1;
2026-03-08 22:24:34 -07:00
state.loadingQuestion = true;
feedbackEl.textContent = "Loading question...";
2026-03-07 01:09:00 -08:00
for (let index = 0; index < maxAttempts; index += 1) {
const template = popNextTemplateFromRun();
if (!template) {
continue;
}
2026-03-08 22:24:34 -07:00
let question = null;
try {
question = await createQuestionFromTemplate(template);
} catch (_error) {
question = null;
}
2026-03-07 01:09:00 -08:00
if (question) {
renderQuestion(question);
return;
}
}
renderNoQuestionState();
}
function submitAnswer(optionIndex) {
if (!state.currentQuestion || state.answeredCurrent) {
return;
}
state.answeredCurrent = true;
state.scoreAnswered += 1;
const isCorrect = optionIndex === state.currentQuestion.correctIndex;
if (isCorrect) {
state.scoreCorrect += 1;
}
const optionButtons = optionsEl.querySelectorAll(".quiz-option");
optionButtons.forEach((button, index) => {
button.disabled = true;
if (index === state.currentQuestion.correctIndex) {
button.classList.add("is-correct");
} else if (index === optionIndex) {
button.classList.add("is-wrong");
}
});
if (isCorrect) {
feedbackEl.textContent = "Correct. Next question incoming…";
} else {
feedbackEl.textContent = `Not quite. Correct answer: ${state.currentQuestion.answer}. Next question incoming…`;
const failedKey = state.currentQuestion.key;
if (!state.runRetrySet.has(failedKey)) {
state.runRetrySet.add(failedKey);
state.runRetryKeys.push(failedKey);
}
}
updateScoreboard();
queueAutoAdvance();
}
function bindEvents() {
if (!isReady()) {
return;
}
categoryEl.addEventListener("change", () => {
state.selectedCategory = String(categoryEl.value || "random");
2026-03-08 22:24:34 -07:00
void startRun(true);
2026-03-07 01:09:00 -08:00
});
difficultyEl.addEventListener("change", () => {
state.selectedDifficulty = String(difficultyEl.value || "normal").toLowerCase();
syncDifficultyControl();
2026-03-08 22:24:34 -07:00
void startRun(true);
2026-03-07 01:09:00 -08:00
});
resetEl.addEventListener("click", () => {
2026-03-08 22:24:34 -07:00
void startRun(true);
2026-03-07 01:09:00 -08:00
});
}
2026-03-08 22:24:34 -07:00
async function ensureQuizSection(referenceData, magickDataset) {
2026-03-07 01:09:00 -08:00
ensureQuizSection._referenceData = referenceData;
ensureQuizSection._magickDataset = magickDataset;
if (!state.initialized) {
getElements();
if (!isReady()) {
return;
}
bindEvents();
state.initialized = true;
updateScoreboard();
}
2026-03-08 22:24:34 -07:00
await refreshQuestionBank(referenceData, magickDataset);
2026-03-07 01:09:00 -08:00
const categoryAdjusted = renderCategoryOptions();
syncDifficultyControl();
if (categoryAdjusted) {
2026-03-08 22:24:34 -07:00
await startRun(false);
2026-03-07 01:09:00 -08:00
return;
}
const hasRunPending = state.runUnseenKeys.length > 0 || state.runRetryKeys.length > 0;
if (!state.currentQuestion && !hasRunPending) {
2026-03-08 22:24:34 -07:00
await startRun(false);
2026-03-07 01:09:00 -08:00
return;
}
if (!state.currentQuestion && hasRunPending) {
2026-03-08 22:24:34 -07:00
await showNextQuestion();
2026-03-07 01:09:00 -08:00
}
updateScoreboard();
}
window.QuizSectionUi = {
ensureQuizSection,
registerQuizCategory
};
})();