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