UNPKG

mcp-quiz-server

Version:

๐Ÿง  AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

354 lines (309 loc) โ€ข 11.5 kB
/** * @moduleName: QuizProgressManager * @version: 1.0.0 * @since: 2025-07-29 * @lastUpdated: 2025-07-29 * @projectSummary: Enhanced MCP Quiz Server - Client-Side Progress Management * @techStack: TypeScript, localStorage, DOM API * @dependency: None * @interModuleDependency: SettingsService.ts, FeedbackManager.ts, AppStore.ts * @requirementsTraceability: * {@link Requirements.REQ_UI_011} (Auto-Advance Question System with Client-Side Progress Management) * {@link Requirements.REQ_QUIZ_005} (Quiz completion and progress tracking) * @briefDescription: Manages quiz progress with auto-advance, feedback display, and client-side state persistence * @methods: handleAnswerSelection, persistProgress, submitCompleteQuiz, recoverProgress * @contributors: GitHub Copilot * @examples: * - const progressManager = new QuizProgressManager(quiz, settings); * - progressManager.handleAnswerSelection(questionId, selectedAnswer); * @vulnerabilitiesAssessment: Client-side only, answers validated on server during final submission */ import { AppStore } from '../store/AppStore'; import { Quiz } from '../types/index'; import { AppSettings } from './SettingsService'; export interface QuizProgress { answers: [string, string][]; // Map entries: [questionId, answer] timestamp: number; quizId: string; currentQuestionIndex: number; timeSpent: number; // milliseconds } export interface AutoAdvanceConfig { enabled: boolean; delay: number; finalQuestionDelay: number; showFeedback: boolean; } export class QuizProgressManager { private answers: Map<string, string> = new Map(); private quizStartTime: number = Date.now(); private currentQuestionIndex: number = 0; private autoAdvanceTimeouts: Set<NodeJS.Timeout> = new Set(); constructor( private quiz: Quiz, private settings: AppSettings, private navigationController: any, private feedbackManager: any ) { this.recoverProgress(); } /** * Handle user answer selection with auto-advance logic (REQ-UI-011) */ handleAnswerSelection(questionId: string, answer: string): void { console.log(`๐Ÿ“ Answer selected: ${questionId} = ${answer}`); // Store answer locally this.answers.set(questionId, answer); this.persistProgress(); // Show immediate feedback if enabled if (this.settings.quiz.showImmediateFeedback) { this.showFeedback(questionId, answer); } // Auto-advance if enabled if (this.settings.quiz.autoAdvanceEnabled && this.isInSingleQuestionMode()) { this.scheduleAutoAdvance(questionId); } } /** * Show immediate feedback for selected answer */ private showFeedback(questionId: string, answer: string): void { const question = this.quiz.questions.find(q => q.id === questionId); if (!question) return; const isCorrect = answer === question.correctAnswer; // Try multiple feedback container strategies (FIX: Critical Gap 2) let feedbackContainer = document.querySelector(`#feedback-container-${questionId}`) || // Question-specific document.querySelector('#feedback-container') || // Global document.querySelector(`[data-question-id="${questionId}"]`); // Question block if (feedbackContainer) { // For question blocks, create or find feedback area within them if (feedbackContainer.hasAttribute('data-question-id')) { let innerContainer = feedbackContainer.querySelector('.feedback-area'); if (!innerContainer) { innerContainer = document.createElement('div'); innerContainer.className = 'feedback-area mt-4'; feedbackContainer.appendChild(innerContainer); } feedbackContainer = innerContainer; } // Show immediate feedback const feedbackHtml = ` <div class="feedback-display p-4 rounded-lg border-2 mt-4 transition-all duration-300 ${ isCorrect ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200' }"> <div class="feedback-status flex items-center mb-2"> <span class="text-lg mr-2">${isCorrect ? 'โœ…' : 'โŒ'}</span> <span class="font-medium ${isCorrect ? 'text-green-700' : 'text-red-700'}"> ${isCorrect ? 'Correct!' : 'Incorrect'} </span> </div> ${ this.settings.quiz.showExplanationsAfterAnswer && question.explanation ? ` <div class="explanation text-gray-700 mt-2"> <strong>Explanation:</strong> ${question.explanation} </div> ` : '' } </div> `; feedbackContainer.innerHTML = feedbackHtml; // Animate feedback appearance const feedbackElement = feedbackContainer.querySelector('.feedback-display'); if (feedbackElement) { feedbackElement.classList.add('animate-fadeIn'); } } else { console.warn(`โš ๏ธ No feedback container found for question ${questionId}`); // Fallback: Show feedback in console for development console.log(`${isCorrect ? 'โœ… Correct!' : 'โŒ Incorrect'} Answer: ${answer}`); } } /** * Schedule auto-advance to next question */ private scheduleAutoAdvance(questionId: string): void { const isLastQuestion = this.isLastQuestion(); const delay = isLastQuestion ? this.settings.quiz.autoAdvanceDelay + 1000 // Extra delay for final question : this.settings.quiz.autoAdvanceDelay; console.log(`โฑ๏ธ Scheduling auto-advance in ${delay}ms (last question: ${isLastQuestion})`); const timeoutId = setTimeout(() => { if (isLastQuestion) { this.submitCompleteQuiz(); } else { this.advanceToNextQuestion(); } this.autoAdvanceTimeouts.delete(timeoutId); }, delay); this.autoAdvanceTimeouts.add(timeoutId); } /** * Advance to the next question */ private advanceToNextQuestion(): void { console.log('โžก๏ธ Auto-advancing to next question'); // Clear feedback const feedbackContainer = document.querySelector('#feedback-container'); if (feedbackContainer) { feedbackContainer.innerHTML = ''; } // Use navigation controller to advance if (this.navigationController && typeof this.navigationController.nextQuestion === 'function') { this.navigationController.nextQuestion(); } else { // Fallback: trigger custom event document.dispatchEvent(new CustomEvent('quiz:next-question')); } this.currentQuestionIndex++; } /** * Submit complete quiz to backend */ async submitCompleteQuiz(): Promise<any> { console.log('๐Ÿš€ Submitting complete quiz with all answers'); try { const response = await fetch('/quiz/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quizId: this.quiz.id, answers: Object.fromEntries(this.answers), completionTime: Date.now() - this.quizStartTime, progressData: { questionsAnswered: this.answers.size, totalQuestions: this.quiz.questions.length, autoAdvanceUsed: this.settings.quiz.autoAdvanceEnabled, }, }), }); if (response.ok) { const result = await response.json(); this.clearProgress(); // Clean up localStorage // Trigger quiz completion event document.dispatchEvent( new CustomEvent('quiz:completed', { detail: { result, answers: Object.fromEntries(this.answers) }, }) ); return result; } else { throw new Error(`Submission failed: ${response.statusText}`); } } catch (error) { console.error('โŒ Quiz submission failed:', error); // Keep progress in case of network failure throw error; } } /** * Persist progress to localStorage */ persistProgress(): void { const progress: QuizProgress = { answers: [...this.answers], timestamp: Date.now(), quizId: this.quiz.id, currentQuestionIndex: this.currentQuestionIndex, timeSpent: Date.now() - this.quizStartTime, }; try { localStorage.setItem('quiz-progress', JSON.stringify(progress)); console.log(`๐Ÿ’พ Progress saved: ${this.answers.size} answers`); } catch (error) { console.warn('Failed to save progress to localStorage:', error); } } /** * Recover progress from localStorage */ recoverProgress(): boolean { try { const progressStr = localStorage.getItem('quiz-progress'); if (!progressStr) return false; const progress: QuizProgress = JSON.parse(progressStr); // Verify it's for the same quiz if (progress.quizId !== this.quiz.id) { this.clearProgress(); // Clear stale progress return false; } // Restore answers this.answers = new Map(progress.answers); this.currentQuestionIndex = progress.currentQuestionIndex || 0; // Adjust start time to account for previous session this.quizStartTime = Date.now() - (progress.timeSpent || 0); console.log( `๐Ÿ”„ Progress recovered: ${this.answers.size} answers, question ${this.currentQuestionIndex}` ); return true; } catch (error) { console.warn('Failed to recover progress:', error); this.clearProgress(); return false; } } /** * Clear progress from localStorage */ clearProgress(): void { try { localStorage.removeItem('quiz-progress'); console.log('๐Ÿ—‘๏ธ Progress cleared from localStorage'); } catch (error) { console.warn('Failed to clear progress:', error); } } /** * Cancel any pending auto-advance timeouts */ cancelAutoAdvance(): void { this.autoAdvanceTimeouts.forEach(timeoutId => { clearTimeout(timeoutId); }); this.autoAdvanceTimeouts.clear(); console.log('โน๏ธ Auto-advance cancelled'); } /** * Get current answers map */ getAnswers(): Map<string, string> { return new Map(this.answers); } /** * Get completion statistics */ getCompletionStats(): { answered: number; total: number; percentage: number } { const answered = this.answers.size; const total = this.quiz.questions.length; return { answered, total, percentage: total > 0 ? Math.round((answered / total) * 100) : 0, }; } /** * Check if currently in single question mode */ private isInSingleQuestionMode(): boolean { // Use AppStore instead of DOM selector for reliable state (FIX: Critical Gap 1) const appStore = AppStore.getInstance(); const state = appStore.getState(); return state.ui.viewMode === 'single'; } /** * Check if current question is the last one */ private isLastQuestion(): boolean { return this.currentQuestionIndex >= this.quiz.questions.length - 1; } /** * Clean up resources */ destroy(): void { this.cancelAutoAdvance(); this.persistProgress(); // Final save console.log('๐Ÿงน QuizProgressManager cleanup completed'); } }