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
text/typescript
/**
* @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');
}
}