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.

450 lines (380 loc) โ€ข 15.4 kB
/** * @moduleName: FeedbackManager * @version: 2.0.0 * @since: 2025-07-26 * @lastUpdated: 2025-07-26 * @projectSummary: Enhanced MCP Quiz Server - Educational Feedback System * @techStack: TypeScript, DOM API, Animation API * @dependency: EventManagement, SettingsService * @interModuleDependency: EventManagement for cleanup, SettingsService for configuration * @requirementsTraceability: * {@link Requirements.REQ_EDU_001} (Educational Feedback System for Enhanced Learning) * {@link Requirements.REQ_UI_002} (Dual View Mode System - Cross-mode Support) * @briefDescription: Manages immediate feedback, explanations, and coaching modes for quiz interactions * @methods: showImmediateFeedback, showExplanation, enableCoachingMode, showCelebration * @contributors: GitHub Copilot * @examples: * - const feedback = new FeedbackManager(settings); feedback.showImmediateFeedback(element, true); * @vulnerabilitiesAssessment: DOM manipulation with sanitization, no sensitive data exposure */ import { SettingsService } from '../../services/SettingsService'; export class FeedbackManager { private settingsService: SettingsService; private activeFeedback: Set<string> = new Set(); constructor(settingsService: SettingsService) { this.settingsService = settingsService; } /** * Show immediate visual feedback for answer selection * @param element - The answer element that was selected * @param isCorrect - Whether the answer is correct * @param questionId - The question ID for tracking */ showImmediateFeedback(element: Element, isCorrect: boolean, questionId: string): void { const settings = this.settingsService.getSettings(); if (!settings.quiz.showImmediateFeedback) { return; } console.log(`๐Ÿ’ก Showing immediate feedback: ${isCorrect ? 'Correct' : 'Incorrect'}`); // Remove any existing feedback classes this.clearFeedback(element); // Add appropriate feedback styling const feedbackClasses = isCorrect ? ['bg-green-100', 'border-green-500', 'dark:bg-green-900/20', 'animate-pulse'] : ['bg-red-100', 'border-red-500', 'dark:bg-red-900/20', 'animate-pulse']; element.classList.add(...feedbackClasses); // Add visual indicator this.addFeedbackIcon(element, isCorrect); // Show coaching feedback if enabled if (settings.quiz.enableCoachingMode) { this.showCoachingFeedback(element, isCorrect, questionId); } // Show explanation if enabled and answer is selected if (settings.quiz.showExplanationsAfterAnswer) { setTimeout(() => { this.showExplanation(questionId, isCorrect); }, 800); } // Track active feedback this.activeFeedback.add(questionId); // Remove pulse animation after 2 seconds setTimeout(() => { element.classList.remove('animate-pulse'); }, 2000); // Announce to screen readers this.announceToScreenReader( isCorrect ? 'Correct answer selected' : 'Incorrect answer selected' ); } /** * Show explanation text for a question * @param questionId - The question to show explanation for * @param wasCorrect - Whether the user's answer was correct */ showExplanation(questionId: string, wasCorrect: boolean): void { const settings = this.settingsService.getSettings(); if (!settings.quiz.showExplanationsAfterAnswer) { return; } console.log(`๐Ÿ“– Showing explanation for question: ${questionId}`); // Find the question container const questionContainer = document.querySelector(`[data-question-id="${questionId}"]`); if (!questionContainer) { console.warn('โš ๏ธ Question container not found for explanation'); return; } // Check if explanation already exists const existingExplanation = questionContainer.querySelector('.explanation-container'); if (existingExplanation) { return; } // Get explanation text (this would come from question data in real implementation) const explanationText = this.getExplanationText(questionId); if (!explanationText) { return; } // Create explanation container const explanationContainer = document.createElement('div'); explanationContainer.className = ` explanation-container mt-4 p-4 rounded-lg border-l-4 ${ wasCorrect ? 'bg-green-50 border-green-400 dark:bg-green-900/10 dark:border-green-500' : 'bg-blue-50 border-blue-400 dark:bg-blue-900/10 dark:border-blue-500' } animate-fadeIn `; explanationContainer.innerHTML = ` <div class="flex items-start"> <div class="flex-shrink-0"> ${ wasCorrect ? '<svg class="w-5 h-5 text-green-600 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>' : '<svg class="w-5 h-5 text-blue-600 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>' } </div> <div class="ml-3"> <h4 class="text-sm font-medium ${wasCorrect ? 'text-green-800 dark:text-green-200' : 'text-blue-800 dark:text-blue-200'}"> ${wasCorrect ? 'Great job!' : 'Learn more:'} </h4> <div class="mt-1 text-sm ${wasCorrect ? 'text-green-700 dark:text-green-300' : 'text-blue-700 dark:text-blue-300'}"> ${explanationText} </div> </div> </div> `; // Add to question container questionContainer.appendChild(explanationContainer); // Add fade-in animation setTimeout(() => { explanationContainer.classList.add('opacity-100'); }, 50); } /** * Show coaching-style feedback * @param element - The selected element * @param isCorrect - Whether the answer is correct * @param questionId - The question ID */ showCoachingFeedback(element: Element, isCorrect: boolean, questionId: string): void { const settings = this.settingsService.getSettings(); if (!settings.quiz.enableCoachingMode) { return; } console.log(`๐Ÿƒโ€โ™‚๏ธ Showing coaching feedback for: ${questionId}`); // Get coaching intensity setting const intensity = settings.quiz.coachingIntensity || 'detailed'; const coachingMessages = this.getCoachingMessages(isCorrect, intensity); const message = coachingMessages[Math.floor(Math.random() * coachingMessages.length)]; // Create coaching tooltip this.showCoachingTooltip(element, message, isCorrect); } /** * Show celebration effects for correct answers * @param element - The element to celebrate around * @param intensity - Celebration intensity ('subtle', 'normal', 'enthusiastic') */ showCelebration( element: Element, intensity: 'subtle' | 'normal' | 'enthusiastic' = 'normal' ): void { console.log(`๐ŸŽ‰ Showing ${intensity} celebration`); switch (intensity) { case 'subtle': this.showSubtleCelebration(element); break; case 'enthusiastic': this.showEnthusiasticCelebration(element); break; default: this.showNormalCelebration(element); } } /** * Clear feedback from an element * @param element - The element to clear feedback from */ clearFeedback(element: Element): void { const feedbackClasses = [ 'bg-green-100', 'border-green-500', 'dark:bg-green-900/20', 'bg-red-100', 'border-red-500', 'dark:bg-red-900/20', 'animate-pulse', ]; element.classList.remove(...feedbackClasses); // Remove feedback icons const feedbackIcon = element.querySelector('.feedback-icon'); if (feedbackIcon) { feedbackIcon.remove(); } } /** * Clear all active feedback */ clearAllFeedback(): void { this.activeFeedback.forEach(questionId => { const questionContainer = document.querySelector(`[data-question-id="${questionId}"]`); if (questionContainer) { // Clear visual feedback const answerElements = questionContainer.querySelectorAll('.quiz-option-card, label'); answerElements.forEach(element => this.clearFeedback(element)); // Remove explanations const explanations = questionContainer.querySelectorAll('.explanation-container'); explanations.forEach(explanation => explanation.remove()); } }); this.activeFeedback.clear(); console.log('๐Ÿงน All feedback cleared'); } /** * Add feedback icon to element */ private addFeedbackIcon(element: Element, isCorrect: boolean): void { // Remove existing icon const existingIcon = element.querySelector('.feedback-icon'); if (existingIcon) { existingIcon.remove(); } const icon = document.createElement('div'); icon.className = 'feedback-icon absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center'; if (isCorrect) { icon.classList.add('bg-green-500', 'text-white'); icon.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>'; } else { icon.classList.add('bg-red-500', 'text-white'); icon.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>'; } // Position the element relatively if it's not already if (getComputedStyle(element).position === 'static') { element.classList.add('relative'); } element.appendChild(icon); } /** * Show coaching tooltip */ private showCoachingTooltip(element: Element, message: string, isCorrect: boolean): void { const tooltip = document.createElement('div'); tooltip.className = ` coaching-tooltip absolute z-50 px-3 py-2 text-sm rounded-lg shadow-lg max-w-xs ${isCorrect ? 'bg-green-600 text-white' : 'bg-blue-600 text-white'} animate-fadeIn `; tooltip.textContent = message; // Position tooltip const rect = element.getBoundingClientRect(); tooltip.style.top = `${rect.bottom + 8}px`; tooltip.style.left = `${rect.left}px`; document.body.appendChild(tooltip); // Remove tooltip after 3 seconds setTimeout(() => { tooltip.remove(); }, 3000); } /** * Get explanation text for a question (placeholder - would fetch from question data) */ private getExplanationText(questionId: string): string { // In a real implementation, this would fetch from the question data return "This explanation would be loaded from the question's explanation field."; } /** * Get coaching messages based on correctness and intensity */ private getCoachingMessages(isCorrect: boolean, intensity: string): string[] { if (isCorrect) { switch (intensity) { case 'comprehensive': return ['Excellent work!', 'Outstanding!', 'Perfect!', 'You nailed it!']; case 'basic': return ['Correct', 'Good', 'Right']; default: return ['Great job!', 'Well done!', 'Correct!', 'Nice work!']; } } else { switch (intensity) { case 'comprehensive': return [ 'Not quite right, but keep trying!', 'Good effort! Try again.', 'Close! Give it another shot.', ]; case 'basic': return ['Try again', 'Not correct']; default: return ['Not quite right', 'Try again!', 'Keep going!']; } } } /** * Show subtle celebration */ private showSubtleCelebration(element: Element): void { element.classList.add('animate-bounce'); setTimeout(() => { element.classList.remove('animate-bounce'); }, 1000); } /** * Show normal celebration */ private showNormalCelebration(element: Element): void { // Add celebration animation element.classList.add('animate-bounce'); // Create success particles this.createSuccessParticles(element); setTimeout(() => { element.classList.remove('animate-bounce'); }, 1500); } /** * Show enthusiastic celebration */ private showEnthusiasticCelebration(element: Element): void { // Multiple animation effects element.classList.add('animate-bounce'); // Create more particles for (let i = 0; i < 3; i++) { setTimeout(() => { this.createSuccessParticles(element); }, i * 200); } setTimeout(() => { element.classList.remove('animate-bounce'); }, 2000); } /** * Create success particles animation */ private createSuccessParticles(element: Element): void { const rect = element.getBoundingClientRect(); const particles = ['๐ŸŽ‰', 'โœจ', '๐ŸŒŸ', '๐Ÿ’ซ']; for (let i = 0; i < 5; i++) { const particle = document.createElement('div'); particle.textContent = particles[Math.floor(Math.random() * particles.length)]; particle.className = 'fixed pointer-events-none z-50 text-2xl animate-bounce'; particle.style.left = `${rect.left + Math.random() * rect.width}px`; particle.style.top = `${rect.top + Math.random() * rect.height}px`; document.body.appendChild(particle); // Animate and remove setTimeout(() => { particle.style.transform = `translateY(-50px) scale(0)`; particle.style.opacity = '0'; particle.style.transition = 'all 1s ease-out'; setTimeout(() => { particle.remove(); }, 1000); }, 100); } } /** * Announce message to screen readers */ private announceToScreenReader(message: string): void { const announcement = document.createElement('div'); announcement.setAttribute('aria-live', 'polite'); announcement.setAttribute('aria-atomic', 'true'); announcement.className = 'sr-only'; announcement.textContent = message; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); } /** * Clean up feedback manager */ cleanup(): void { this.clearAllFeedback(); // Remove any lingering tooltips or particles const tooltips = document.querySelectorAll('.coaching-tooltip'); tooltips.forEach(tooltip => tooltip.remove()); console.log('๐Ÿงน FeedbackManager cleanup completed'); } }