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