initial commit
This commit is contained in:
600
static/js/modules/exam-mode.js
Normal file
600
static/js/modules/exam-mode.js
Normal file
@@ -0,0 +1,600 @@
|
||||
// Exam Mode Module
|
||||
class ExamMode extends BaseModule {
|
||||
constructor() {
|
||||
super();
|
||||
this.examAnswers = {};
|
||||
this.examTimer = null;
|
||||
this.examTimeLimit = 30; // minutes
|
||||
this.examStartTime = null;
|
||||
this.navigator = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.getElementById('start-exam')?.addEventListener('click', () => {
|
||||
this.startExam();
|
||||
});
|
||||
|
||||
document.getElementById('prev-exam-btn')?.addEventListener('click', () => {
|
||||
this.previousQuestion();
|
||||
});
|
||||
|
||||
document.getElementById('next-exam-btn')?.addEventListener('click', () => {
|
||||
this.nextQuestion();
|
||||
});
|
||||
|
||||
document.getElementById('finish-exam')?.addEventListener('click', () => {
|
||||
this.showFinishConfirmation();
|
||||
});
|
||||
|
||||
document.getElementById('retake-exam')?.addEventListener('click', () => {
|
||||
this.resetExam();
|
||||
});
|
||||
|
||||
document.getElementById('review-answers')?.addEventListener('click', () => {
|
||||
this.showReviewMode();
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
this.handleKeyboard(e);
|
||||
});
|
||||
|
||||
// Modal event listeners
|
||||
this.setupModalListeners();
|
||||
}
|
||||
|
||||
setupModalListeners() {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'confirm-finish') {
|
||||
this.finishExam();
|
||||
this.hideConfirmModal();
|
||||
} else if (e.target.id === 'confirmModalCancel' || e.target.id === 'confirmModalClose') {
|
||||
this.hideConfirmModal();
|
||||
} else if (e.target.id === 'confirmBackdrop') {
|
||||
this.hideConfirmModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async startExam() {
|
||||
const questionCount = parseInt(document.getElementById('exam-questions').value);
|
||||
this.examTimeLimit = parseInt(document.getElementById('exam-time').value);
|
||||
|
||||
try {
|
||||
const data = await this.fetchQuestions({
|
||||
mode: 'random',
|
||||
count: questionCount
|
||||
});
|
||||
|
||||
this.questions = data;
|
||||
this.examAnswers = {};
|
||||
this.currentIndex = 0;
|
||||
this.examStartTime = new Date();
|
||||
|
||||
this.initializeNavigator();
|
||||
this.displayQuestion();
|
||||
this.startTimer();
|
||||
|
||||
BaseModule.hide('exam-setup');
|
||||
BaseModule.show('exam-content');
|
||||
|
||||
this.updateExamProgress();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting exam:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initializeNavigator() {
|
||||
if (!this.navigator) {
|
||||
this.navigator = new QuestionNavigator('question-navigator', {
|
||||
onQuestionSelect: (index) => {
|
||||
this.currentIndex = index;
|
||||
this.displayQuestion();
|
||||
},
|
||||
storagePrefix: 'exam_temp' // Don't persist exam navigation
|
||||
});
|
||||
}
|
||||
|
||||
this.navigator.setQuestions(this.questions);
|
||||
}
|
||||
|
||||
displayQuestion() {
|
||||
if (this.currentIndex >= this.questions.length) return;
|
||||
|
||||
const question = this.questions[this.currentIndex];
|
||||
|
||||
let html = this.createQuestionHTML(question, this.currentIndex, this.questions.length);
|
||||
html += this.createExamOptionsHTML(question.options);
|
||||
|
||||
document.getElementById('exam-question-container').innerHTML = html;
|
||||
document.getElementById('question-progress').textContent = `${this.currentIndex + 1} de ${this.questions.length}`;
|
||||
|
||||
// Setup option click handlers
|
||||
this.setupExamOptionHandlers(question);
|
||||
|
||||
this.updateExamNavigation();
|
||||
if (this.navigator) {
|
||||
this.navigator.setCurrentIndex(this.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
setupExamOptionHandlers(question) {
|
||||
const optionButtons = document.querySelectorAll('.option-enhanced');
|
||||
optionButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
this.playSound('click');
|
||||
optionButtons.forEach(btn => btn.classList.remove('selected'));
|
||||
button.classList.add('selected');
|
||||
|
||||
const selectedAnswer = button.getAttribute('data-answer');
|
||||
this.examAnswers[question.id] = selectedAnswer;
|
||||
|
||||
if (this.navigator) {
|
||||
this.navigator.updateNavigator();
|
||||
}
|
||||
this.updateExamProgress();
|
||||
});
|
||||
});
|
||||
|
||||
// Pre-select if already answered
|
||||
if (this.examAnswers[question.id]) {
|
||||
const selectedButton = document.querySelector(`[data-answer="${this.examAnswers[question.id]}"]`);
|
||||
if (selectedButton) {
|
||||
selectedButton.classList.add('selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousQuestion() {
|
||||
if (this.currentIndex > 0) {
|
||||
this.currentIndex--;
|
||||
this.displayQuestion();
|
||||
}
|
||||
}
|
||||
|
||||
nextQuestion() {
|
||||
if (this.currentIndex < this.questions.length - 1) {
|
||||
this.currentIndex++;
|
||||
this.displayQuestion();
|
||||
}
|
||||
}
|
||||
|
||||
updateExamNavigation() {
|
||||
document.getElementById('prev-exam-btn').disabled = this.currentIndex === 0;
|
||||
document.getElementById('next-exam-btn').disabled = this.currentIndex === this.questions.length - 1;
|
||||
}
|
||||
|
||||
updateExamProgress() {
|
||||
const answeredCount = Object.keys(this.examAnswers).length;
|
||||
const remainingCount = this.questions.length - answeredCount;
|
||||
|
||||
document.getElementById('answered-count').textContent = answeredCount;
|
||||
document.getElementById('remaining-count').textContent = remainingCount;
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
const endTime = new Date(this.examStartTime.getTime() + this.examTimeLimit * 60000);
|
||||
|
||||
this.examTimer = setInterval(() => {
|
||||
const now = new Date();
|
||||
const timeLeft = endTime - now;
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(this.examTimer);
|
||||
this.finishExam();
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(timeLeft / 60000);
|
||||
const seconds = Math.floor((timeLeft % 60000) / 1000);
|
||||
|
||||
document.getElementById('timer').textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
// Change color when time is running out
|
||||
const timerElement = document.getElementById('timer');
|
||||
if (minutes < 5) {
|
||||
timerElement.classList.remove('text-warning');
|
||||
timerElement.classList.add('text-danger');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
showFinishConfirmation() {
|
||||
const unansweredCount = this.questions.length - Object.keys(this.examAnswers).length;
|
||||
document.getElementById('unanswered-warning').textContent = unansweredCount;
|
||||
|
||||
document.getElementById('confirmModal').style.display = 'block';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
hideConfirmModal() {
|
||||
document.getElementById('confirmModal').style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
finishExam() {
|
||||
clearInterval(this.examTimer);
|
||||
|
||||
let correct = 0;
|
||||
let incorrect = 0;
|
||||
let unanswered = 0;
|
||||
|
||||
this.questions.forEach(question => {
|
||||
if (this.examAnswers.hasOwnProperty(question.id)) {
|
||||
if (this.examAnswers[question.id] === question.correct) {
|
||||
correct++;
|
||||
} else {
|
||||
incorrect++;
|
||||
}
|
||||
} else {
|
||||
unanswered++;
|
||||
}
|
||||
});
|
||||
|
||||
const score = Math.round((correct / this.questions.length) * 100);
|
||||
const passed = score >= 70; // 70% to pass
|
||||
|
||||
// Update global stats
|
||||
if (window.statsManager) {
|
||||
for (let i = 0; i < correct; i++) {
|
||||
window.statsManager.updateStats(true);
|
||||
}
|
||||
for (let i = 0; i < incorrect; i++) {
|
||||
window.statsManager.updateStats(false);
|
||||
}
|
||||
window.statsManager.updateExamScore(score);
|
||||
}
|
||||
|
||||
// Show results
|
||||
this.showExamResults(correct, incorrect, unanswered, score, passed);
|
||||
}
|
||||
|
||||
showExamResults(correct, incorrect, unanswered, score, passed) {
|
||||
document.getElementById('exam-correct').textContent = correct;
|
||||
document.getElementById('exam-incorrect').textContent = incorrect;
|
||||
document.getElementById('exam-unanswered').textContent = unanswered;
|
||||
document.getElementById('exam-score').textContent = score + '%';
|
||||
|
||||
const progressBar = document.getElementById('exam-progress-bar');
|
||||
progressBar.style.width = score + '%';
|
||||
|
||||
const resultIcon = document.getElementById('result-icon');
|
||||
const resultTitle = document.getElementById('result-title');
|
||||
const passMessage = document.getElementById('pass-message');
|
||||
|
||||
if (passed) {
|
||||
progressBar.classList.remove('bg-danger');
|
||||
progressBar.classList.add('bg-success');
|
||||
resultIcon.innerHTML = '<i class="fas fa-trophy fa-4x text-success"></i>';
|
||||
resultTitle.textContent = '¡Felicitaciones! Has Aprobado';
|
||||
passMessage.classList.add('alert-success');
|
||||
passMessage.textContent = 'Has obtenido una calificación aprobatoria. ¡Buen trabajo!';
|
||||
passMessage.style.display = 'block';
|
||||
|
||||
if (window.Utils) {
|
||||
Utils.createConfetti();
|
||||
Utils.playSound('complete');
|
||||
}
|
||||
} else {
|
||||
progressBar.classList.remove('bg-success');
|
||||
progressBar.classList.add('bg-danger');
|
||||
resultIcon.innerHTML = '<i class="fas fa-times-circle fa-4x text-danger"></i>';
|
||||
resultTitle.textContent = 'Examen No Aprobado';
|
||||
passMessage.classList.add('alert-danger');
|
||||
passMessage.textContent = 'Necesitas al menos 70% para aprobar. ¡Sigue estudiando y vuelve a intentarlo!';
|
||||
passMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
BaseModule.hide('exam-content');
|
||||
BaseModule.show('exam-results');
|
||||
}
|
||||
|
||||
resetExam() {
|
||||
this.examAnswers = {};
|
||||
this.currentIndex = 0;
|
||||
clearInterval(this.examTimer);
|
||||
|
||||
BaseModule.hide('exam-results');
|
||||
BaseModule.show('exam-setup');
|
||||
}
|
||||
|
||||
createExamOptionsHTML(options) {
|
||||
let html = '<div class="options-container">';
|
||||
options.forEach((option, index) => {
|
||||
const letter = String.fromCharCode(97 + index); // a, b, c, d
|
||||
html += `
|
||||
<button class="option-enhanced" data-answer="${letter}">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-primary me-3">${letter.toUpperCase()}</span>
|
||||
<span>${option.replace(/^[a-d]\)\s*/, '')}</span>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
showReviewMode() {
|
||||
this.reviewIndex = 0;
|
||||
this.setupReviewEventListeners();
|
||||
this.displayReviewQuestion();
|
||||
this.createReviewNavigator();
|
||||
|
||||
BaseModule.hide('exam-results');
|
||||
BaseModule.show('exam-review');
|
||||
}
|
||||
|
||||
setupReviewEventListeners() {
|
||||
// Remove existing listeners to avoid duplicates
|
||||
document.getElementById('prev-review-btn')?.removeEventListener('click', this.prevReviewHandler);
|
||||
document.getElementById('next-review-btn')?.removeEventListener('click', this.nextReviewHandler);
|
||||
document.getElementById('back-to-results')?.removeEventListener('click', this.backToResultsHandler);
|
||||
|
||||
// Create bound handlers
|
||||
this.prevReviewHandler = () => this.previousReviewQuestion();
|
||||
this.nextReviewHandler = () => this.nextReviewQuestion();
|
||||
this.backToResultsHandler = () => this.backToResults();
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('prev-review-btn')?.addEventListener('click', this.prevReviewHandler);
|
||||
document.getElementById('next-review-btn')?.addEventListener('click', this.nextReviewHandler);
|
||||
document.getElementById('back-to-results')?.addEventListener('click', this.backToResultsHandler);
|
||||
}
|
||||
|
||||
displayReviewQuestion() {
|
||||
if (this.reviewIndex >= this.questions.length) return;
|
||||
|
||||
const question = this.questions[this.reviewIndex];
|
||||
const userAnswer = this.examAnswers[question.id];
|
||||
const isCorrect = userAnswer === question.correct;
|
||||
const wasAnswered = userAnswer !== undefined;
|
||||
|
||||
let html = `
|
||||
<div class="question-card-enhanced fade-in">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0 text-gradient">Pregunta ${question.id}</h5>
|
||||
<div>
|
||||
<span class="badge bg-primary me-2">${this.reviewIndex + 1} de ${this.questions.length}</span>
|
||||
${wasAnswered ?
|
||||
(isCorrect ?
|
||||
'<span class="badge bg-success"><i class="fas fa-check me-1"></i>Correcta</span>' :
|
||||
'<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Incorrecta</span>'
|
||||
) :
|
||||
'<span class="badge bg-secondary"><i class="fas fa-question me-1"></i>Sin responder</span>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (question.has_image && question.image) {
|
||||
html += `<img src="${question.image}" class="question-image" alt="Imagen de la pregunta">`;
|
||||
}
|
||||
|
||||
html += `<p class="lead mb-4">${question.question}</p>`;
|
||||
|
||||
html += '<div class="options-container">';
|
||||
question.options.forEach((option, index) => {
|
||||
const letter = String.fromCharCode(97 + index); // a, b, c, d
|
||||
const isUserAnswer = letter === userAnswer;
|
||||
const isCorrectAnswer = letter === question.correct;
|
||||
|
||||
let optionClass = 'option-enhanced';
|
||||
let badgeClass = 'bg-primary';
|
||||
let iconHtml = '';
|
||||
|
||||
if (isCorrectAnswer) {
|
||||
optionClass += ' correct';
|
||||
badgeClass = 'bg-success';
|
||||
iconHtml = '<i class="fas fa-check ms-2 text-success"></i>';
|
||||
} else if (isUserAnswer && !isCorrect) {
|
||||
optionClass += ' incorrect';
|
||||
badgeClass = 'bg-danger';
|
||||
iconHtml = '<i class="fas fa-times ms-2 text-danger"></i>';
|
||||
} else if (isUserAnswer) {
|
||||
optionClass += ' selected';
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="${optionClass}">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge ${badgeClass} me-3">${letter.toUpperCase()}</span>
|
||||
<span>${option.replace(/^[a-d]\)\s*/, '')}</span>
|
||||
</div>
|
||||
${iconHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
// Add explanation if available
|
||||
if (!wasAnswered) {
|
||||
html += `
|
||||
<div class="alert alert-warning mt-3">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>No respondida:</strong> Esta pregunta no fue respondida durante el examen.
|
||||
</div>
|
||||
`;
|
||||
} else if (!isCorrect) {
|
||||
html += `
|
||||
<div class="alert alert-info mt-3">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>Tu respuesta:</strong> ${userAnswer?.toUpperCase()} |
|
||||
<strong>Respuesta correcta:</strong> ${question.correct.toUpperCase()}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="alert alert-success mt-3">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
<strong>¡Correcto!</strong> Seleccionaste la respuesta correcta.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('review-question-container').innerHTML = html;
|
||||
document.getElementById('review-progress').textContent = `${this.reviewIndex + 1} de ${this.questions.length}`;
|
||||
|
||||
this.updateReviewNavigation();
|
||||
this.updateReviewStats();
|
||||
}
|
||||
|
||||
createReviewNavigator() {
|
||||
const navigator = document.getElementById('review-navigator');
|
||||
let html = '';
|
||||
|
||||
this.questions.forEach((question, index) => {
|
||||
const userAnswer = this.examAnswers[question.id];
|
||||
const isCorrect = userAnswer === question.correct;
|
||||
const wasAnswered = userAnswer !== undefined;
|
||||
|
||||
let buttonClass = 'btn btn-sm me-1 mb-1';
|
||||
let iconHtml = '';
|
||||
|
||||
if (wasAnswered) {
|
||||
if (isCorrect) {
|
||||
buttonClass += ' btn-success';
|
||||
iconHtml = '<i class="fas fa-check"></i>';
|
||||
} else {
|
||||
buttonClass += ' btn-danger';
|
||||
iconHtml = '<i class="fas fa-times"></i>';
|
||||
}
|
||||
} else {
|
||||
buttonClass += ' btn-secondary';
|
||||
iconHtml = '<i class="fas fa-question"></i>';
|
||||
}
|
||||
|
||||
if (index === this.reviewIndex) {
|
||||
buttonClass += ' active';
|
||||
}
|
||||
|
||||
html += `
|
||||
<button class="${buttonClass}" data-review-index="${index}">
|
||||
${index + 1} ${iconHtml}
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
|
||||
navigator.innerHTML = html;
|
||||
|
||||
// Add click handlers for navigator buttons
|
||||
navigator.querySelectorAll('button[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
this.reviewIndex = parseInt(button.getAttribute('data-review-index'));
|
||||
this.displayReviewQuestion();
|
||||
this.createReviewNavigator();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateReviewNavigation() {
|
||||
document.getElementById('prev-review-btn').disabled = this.reviewIndex === 0;
|
||||
document.getElementById('next-review-btn').disabled = this.reviewIndex === this.questions.length - 1;
|
||||
}
|
||||
|
||||
updateReviewStats() {
|
||||
let correct = 0, incorrect = 0, unanswered = 0;
|
||||
|
||||
this.questions.forEach(question => {
|
||||
const userAnswer = this.examAnswers[question.id];
|
||||
if (userAnswer === undefined) {
|
||||
unanswered++;
|
||||
} else if (userAnswer === question.correct) {
|
||||
correct++;
|
||||
} else {
|
||||
incorrect++;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('review-correct-count').textContent = correct;
|
||||
document.getElementById('review-incorrect-count').textContent = incorrect;
|
||||
document.getElementById('review-unanswered-count').textContent = unanswered;
|
||||
}
|
||||
|
||||
previousReviewQuestion() {
|
||||
if (this.reviewIndex > 0) {
|
||||
this.reviewIndex--;
|
||||
this.displayReviewQuestion();
|
||||
this.createReviewNavigator();
|
||||
}
|
||||
}
|
||||
|
||||
nextReviewQuestion() {
|
||||
if (this.reviewIndex < this.questions.length - 1) {
|
||||
this.reviewIndex++;
|
||||
this.displayReviewQuestion();
|
||||
this.createReviewNavigator();
|
||||
}
|
||||
}
|
||||
|
||||
backToResults() {
|
||||
BaseModule.hide('exam-review');
|
||||
BaseModule.show('exam-results');
|
||||
}
|
||||
|
||||
handleKeyboard(e) {
|
||||
// Handle keyboard shortcuts during exam
|
||||
if (document.getElementById('exam-content') && document.getElementById('exam-content').style.display !== 'none') {
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
// Handle option selection (a, b, c, d)
|
||||
if (['a', 'b', 'c', 'd'].includes(key)) {
|
||||
e.preventDefault();
|
||||
const optionButton = document.querySelector(`.option-enhanced[data-answer="${key}"]`);
|
||||
if (optionButton) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.option-enhanced').forEach(btn => btn.classList.remove('selected'));
|
||||
|
||||
// Select the new option
|
||||
optionButton.classList.add('selected');
|
||||
|
||||
// Save the answer
|
||||
const question = this.questions[this.currentIndex];
|
||||
this.examAnswers[question.id] = key;
|
||||
|
||||
if (this.navigator) {
|
||||
this.navigator.updateNavigator();
|
||||
}
|
||||
this.updateExamProgress();
|
||||
this.playSound('click');
|
||||
}
|
||||
}
|
||||
// Navigation keys
|
||||
else if (e.key === 'ArrowLeft' && this.currentIndex > 0) {
|
||||
this.previousQuestion();
|
||||
} else if (e.key === 'ArrowRight' && this.currentIndex < this.questions.length - 1) {
|
||||
this.nextQuestion();
|
||||
}
|
||||
}
|
||||
// Handle keyboard shortcuts during review
|
||||
else if (document.getElementById('exam-review') && document.getElementById('exam-review').style.display !== 'none') {
|
||||
if (e.key === 'ArrowLeft' && this.reviewIndex > 0) {
|
||||
this.previousReviewQuestion();
|
||||
} else if (e.key === 'ArrowRight' && this.reviewIndex < this.questions.length - 1) {
|
||||
this.nextReviewQuestion();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.backToResults();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.getElementById('exam-setup')) {
|
||||
window.examMode = new ExamMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Export for global use
|
||||
window.ExamMode = ExamMode;
|
||||
Reference in New Issue
Block a user