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.

513 lines (435 loc) โ€ข 17.3 kB
/** * @moduleName: ProgressTracker * @version: 2.0.0 * @since: 2025-07-26 * @lastUpdated: 2025-07-26 * @projectSummary: Enhanced MCP Quiz Server - Progress Tracking System * @techStack: TypeScript, DOM API, CSS Animations * @dependency: AppStore, SettingsService * @interModuleDependency: AppStore for state, SettingsService for configuration * @requirementsTraceability: * {@link Requirements.REQ_UI_002} (Dual View Mode System - Progress Tracking) * {@link Requirements.REQ_EDU_001} (Educational Feedback System) * {@link Requirements.REQ_PERF_006} (Animation Performance Standards) * {@link Requirements.REQ_A11Y_003} (High Contrast Color Modes) * @briefDescription: Manages progress indicators, breadcrumbs, completion tracking, and visual progress feedback * @methods: updateProgress, updateBreadcrumbs, showCompletionIndicator, animateProgress * @contributors: GitHub Copilot * @examples: * - const tracker = new ProgressTracker(store, settings); tracker.updateProgress(); * @vulnerabilitiesAssessment: DOM manipulation only, no sensitive data, performance optimized */ import { SettingsService } from '../../services/SettingsService'; import { AppStore } from '../../store/AppStore'; export class ProgressTracker { private store: AppStore; private settingsService: SettingsService; private progressBar: HTMLElement | null = null; private breadcrumbs: HTMLElement | null = null; private statusText: HTMLElement | null = null; private timerDisplay: HTMLElement | null = null; constructor(store: AppStore, settingsService: SettingsService) { this.store = store; this.settingsService = settingsService; this.initializeElements(); } /** * Initialize progress tracking elements */ private initializeElements(): void { // Use specific quiz-related selectors to avoid conflicts with settings menu this.progressBar = document.querySelector('#quiz-progress-bar') as HTMLElement; this.breadcrumbs = document.querySelector('.breadcrumbs-container') as HTMLElement; this.statusText = document.querySelector('#quiz-progress-text') as HTMLElement; this.timerDisplay = document.querySelector('#timer-display') as HTMLElement; console.log('๐Ÿ“Š ProgressTracker initialized with specific selectors'); } /** * Update all progress indicators * @param currentQuestionIndex - Current question index (0-based) * @param totalQuestions - Total number of questions * @param answeredCount - Number of answered questions */ updateProgress( currentQuestionIndex: number, totalQuestions: number, answeredCount: number ): void { // DEBUG: Enhanced logging for progress tracker visual bug console.log(`๐Ÿ“ˆ ProgressTracker.updateProgress DEBUG:`, { currentQuestionIndex, totalQuestions, answeredCount, timestamp: new Date().toISOString(), }); // Update progress bar this.updateProgressBar(currentQuestionIndex, totalQuestions); // Update status text this.updateStatusText(currentQuestionIndex, totalQuestions, answeredCount); // Update breadcrumbs if enabled this.updateBreadcrumbs(currentQuestionIndex, totalQuestions, answeredCount); // Update completion percentage this.updateCompletionPercentage(answeredCount, totalQuestions); } /** * Update the main progress bar */ private updateProgressBar(currentIndex: number, total: number): void { if (!this.progressBar) return; const percentage = ((currentIndex + 1) / total) * 100; // Animate the progress bar this.progressBar.style.transition = 'width 0.3s ease-out'; this.progressBar.style.width = `${percentage}%`; // Add pulsing effect when progressing this.progressBar.classList.add('animate-pulse'); setTimeout(() => { this.progressBar?.classList.remove('animate-pulse'); }, 300); console.log(`๐Ÿ“Š Progress bar updated: ${percentage.toFixed(1)}%`); } /** * Update status text */ private updateStatusText(currentIndex: number, total: number, answeredCount: number): void { if (!this.statusText) return; const state = this.store.getState(); if (state.ui.viewMode === 'single') { this.statusText.textContent = `Question ${currentIndex + 1} of ${total}`; } else { this.statusText.textContent = `Quiz Progress: ${answeredCount}/${total} answered`; } } /** * Update breadcrumb navigation */ private updateBreadcrumbs(currentIndex: number, total: number, answeredCount: number): void { const state = this.store.getState(); // Only show breadcrumbs in list mode or if explicitly requested if (state.ui.viewMode === 'single' && total > 10) { return; // Skip breadcrumbs for long quizzes in single mode } // Create breadcrumbs container if it doesn't exist if (!this.breadcrumbs) { this.createBreadcrumbsContainer(); } if (!this.breadcrumbs) return; // Generate breadcrumb items const breadcrumbsHtml = this.generateBreadcrumbsHtml(currentIndex, total, answeredCount); this.breadcrumbs.innerHTML = breadcrumbsHtml; console.log('๐Ÿž Breadcrumbs updated'); } /** * Create breadcrumbs container */ private createBreadcrumbsContainer(): void { const container = document.createElement('div'); container.className = 'breadcrumbs-container mt-2 flex items-center justify-center space-x-2'; // Insert after status text or at the top of quiz container const insertPoint = this.statusText?.parentNode || document.querySelector('#quiz-container'); if (insertPoint) { if (this.statusText) { insertPoint.insertBefore(container, this.statusText.nextSibling); } else { insertPoint.appendChild(container); } this.breadcrumbs = container; } } /** * Generate breadcrumbs HTML */ private generateBreadcrumbsHtml( currentIndex: number, total: number, answeredCount: number ): string { const state = this.store.getState(); const userAnswers = state.userAnswers; const quiz = state.currentQuiz; if (!quiz) return ''; // DEBUG: Log state for progress tracker issue console.log(`๐Ÿž Breadcrumbs DEBUG:`, { currentIndex, total, answeredCount, userAnswersCount: Object.keys(userAnswers).length, }); const items: string[] = []; for (let i = 0; i < total; i++) { const question = quiz.questions[i]; const isAnswered = userAnswers[question.id] !== undefined; const isCurrent = i === currentIndex; // Use passed currentIndex, not state let className = 'w-3 h-3 rounded-full transition-all duration-200 '; let clickable = false; // DEBUG: Log each circle's state console.log(`๐Ÿ”ต Circle ${i + 1}:`, { isCurrent, isAnswered, questionId: question.id, userAnswer: userAnswers[question.id], }); if (isCurrent) { className += 'bg-blue-600 ring-2 ring-blue-300 scale-125'; } else if (isAnswered) { className += 'bg-green-500 hover:bg-green-600 cursor-pointer'; clickable = true; } else { className += 'bg-gray-300 dark:bg-gray-600'; } const clickHandler = clickable ? `onclick="window.quizNavigation?.goToQuestion(${i})"` : ''; items.push(` <div class="${className}" ${clickHandler} title="Question ${i + 1}${isAnswered ? ' (Answered)' : ''}${isCurrent ? ' (Current)' : ''}" aria-label="Question ${i + 1}${isAnswered ? ' answered' : ''}${isCurrent ? ' current' : ''}"> </div> `); } return items.join(''); } /** * Update completion percentage display */ private updateCompletionPercentage(answeredCount: number, totalQuestions: number): void { const percentage = Math.round((answeredCount / totalQuestions) * 100); // Update any completion percentage displays const percentageElements = document.querySelectorAll('.completion-percentage'); percentageElements.forEach(element => { element.textContent = `${percentage}%`; }); // Update document title with progress - only if no modals are open if (!this.isAnyModalOpen()) { document.title = `Quiz Progress: ${percentage}% - MCP Quiz Server`; } } /** * Check if any modal is currently open to avoid title conflicts * @returns true if any modal is open, false otherwise */ private isAnyModalOpen(): boolean { // Check for settings modal const settingsModal = document.querySelector('#settings-dropdown'); if (settingsModal && !settingsModal.classList.contains('hidden')) { return true; } // Check for other common modals const commonModalSelectors = [ '#quiz-start-overlay', '#quiz-results-overlay', '#tour-modal', '.modal-overlay:not(.hidden)', '[data-modal]:not(.hidden)', ]; return commonModalSelectors.some(selector => { const modal = document.querySelector(selector); return ( modal && !modal.classList.contains('hidden') && getComputedStyle(modal).display !== 'none' ); }); } /** * Show completion indicator animation */ showCompletionIndicator(score: number, totalQuestions: number): void { const percentage = Math.round((score / totalQuestions) * 100); console.log(`๐ŸŽฏ Showing completion: ${score}/${totalQuestions} (${percentage}%)`); // Create completion overlay const overlay = document.createElement('div'); overlay.className = ` fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 animate-fadeIn `; overlay.innerHTML = ` <div class="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md mx-4 text-center animate-slideInUp"> <div class="text-6xl mb-4"> ${this.getCompletionEmoji(percentage)} </div> <h3 class="text-2xl font-bold mb-2 text-gray-900 dark:text-gray-100"> ${this.getCompletionTitle(percentage)} </h3> <p class="text-lg text-gray-600 dark:text-gray-300 mb-4"> You scored ${score} out of ${totalQuestions} questions </p> <div class="w-full bg-gray-200 rounded-full h-4 mb-4"> <div class="bg-gradient-to-r from-blue-500 to-green-500 h-4 rounded-full transition-all duration-1000 ease-out" style="width: 0%" data-final-width="${percentage}%"> </div> </div> <p class="text-2xl font-bold ${this.getPercentageColor(percentage)}"> ${percentage}% </p> </div> `; document.body.appendChild(overlay); // Animate progress bar fill setTimeout(() => { const progressBar = overlay.querySelector('[data-final-width]') as HTMLElement; if (progressBar) { progressBar.style.width = progressBar.dataset.finalWidth || '0%'; } }, 500); // Auto-remove after 5 seconds setTimeout(() => { overlay.classList.add('animate-fadeOut'); setTimeout(() => { overlay.remove(); }, 300); }, 5000); // Click to dismiss overlay.addEventListener('click', () => { overlay.remove(); }); } /** * Show progress milestone (e.g., halfway point) */ showMilestone(milestone: string, currentProgress: number): void { console.log(`๐ŸŽ–๏ธ Milestone reached: ${milestone}`); const toast = document.createElement('div'); toast.className = ` fixed top-4 right-4 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg z-40 animate-slideInRight `; toast.innerHTML = ` <div class="flex items-center"> <svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 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> <div> <div class="font-medium">${milestone}</div> <div class="text-sm opacity-90">${currentProgress}% complete</div> </div> </div> `; document.body.appendChild(toast); // Auto-remove after 3 seconds setTimeout(() => { toast.classList.add('animate-slideOutRight'); setTimeout(() => { toast.remove(); }, 300); }, 3000); } /** * Update timer display */ updateTimer(timeLeft: number, totalTime: number): void { if (!this.timerDisplay) return; const minutes = Math.floor(timeLeft / 60); const seconds = timeLeft % 60; const timeString = `${minutes}:${seconds.toString().padStart(2, '0')}`; this.timerDisplay.textContent = timeString; // Add warning colors based on remaining time const percentage = (timeLeft / totalTime) * 100; this.timerDisplay.className = this.timerDisplay.className.replace( /text-(red|yellow|green)-\d+/g, '' ); if (percentage < 10) { this.timerDisplay.classList.add('text-red-600', 'animate-pulse'); } else if (percentage < 25) { this.timerDisplay.classList.add('text-yellow-600'); this.timerDisplay.classList.remove('animate-pulse'); } else { this.timerDisplay.classList.add('text-green-600'); this.timerDisplay.classList.remove('animate-pulse'); } } /** * Get completion emoji based on score percentage */ private getCompletionEmoji(percentage: number): string { if (percentage >= 90) return '๐Ÿ†'; if (percentage >= 80) return '๐ŸŽ‰'; if (percentage >= 70) return '๐Ÿ‘'; if (percentage >= 60) return '๐Ÿ‘Œ'; return '๐Ÿ’ช'; } /** * Get completion title based on score percentage */ private getCompletionTitle(percentage: number): string { if (percentage >= 90) return 'Outstanding!'; if (percentage >= 80) return 'Great Job!'; if (percentage >= 70) return 'Well Done!'; if (percentage >= 60) return 'Good Work!'; return 'Keep Practicing!'; } /** * Get percentage color class based on score */ private getPercentageColor(percentage: number): string { if (percentage >= 80) return 'text-green-600'; if (percentage >= 60) return 'text-yellow-600'; return 'text-red-600'; } /** * Reset all progress indicators */ reset(): void { if (this.progressBar) { this.progressBar.style.width = '0%'; } if (this.statusText) { this.statusText.textContent = 'Ready to start'; } if (this.breadcrumbs) { this.breadcrumbs.innerHTML = ''; } if (this.timerDisplay) { this.timerDisplay.textContent = ''; this.timerDisplay.className = this.timerDisplay.className.replace( /text-(red|yellow|green)-\d+|animate-pulse/g, '' ); } // Reset document title - only if no modals are open if (!this.isAnyModalOpen()) { document.title = 'MCP Quiz Server'; } console.log('๐Ÿ”„ Progress tracker reset'); } /** * Force refresh the document title based on current state * Called when modals are closed to restore progress display */ refreshDocumentTitle(): void { const state = this.store.getState(); if ( state.currentQuiz && state.currentQuiz.questions && state.quiz.currentQuestionIndex !== -1 ) { // Quiz is active, update with current progress const totalQuestions = state.currentQuiz.questions.length; const answeredCount = Object.keys(state.userAnswers).length; const percentage = Math.round((answeredCount / totalQuestions) * 100); if (!this.isAnyModalOpen()) { document.title = `Quiz Progress: ${percentage}% - MCP Quiz Server`; } } else { // No active quiz if (!this.isAnyModalOpen()) { document.title = 'MCP Quiz Server'; } } } /** * Clean up progress tracker */ cleanup(): void { // Remove any completion overlays const overlays = document.querySelectorAll('.fixed.inset-0'); overlays.forEach(overlay => { if (overlay.querySelector('.completion-percentage, .animate-slideInUp')) { overlay.remove(); } }); // Remove milestone toasts const toasts = document.querySelectorAll('.fixed.top-4.right-4'); toasts.forEach(toast => toast.remove()); this.reset(); console.log('๐Ÿงน ProgressTracker cleanup completed'); } }