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.

589 lines (515 loc) 16.2 kB
/** * @fileoverview Reactive state management store * @version 2.0.0 * @requirementsTraceability {@link Requirements.REQ_ARCH_002} - Application state management and reactive data flow * @requirementsTraceability {@link Requirements.REQ_DATA_001} - Quiz data management and persistence layer * @requirementsTraceability {@link Requirements.REQ_DATA_002} - Local storage for user preferences and settings */ import { AuthService } from '../services/AuthService'; import { QuizService } from '../services/QuizService'; import { SettingsManager } from '../services/SettingsManager'; import { AppState, FilterType, Quiz, QuizProgress, User, ViewMode } from '../types/index'; export class AppStore { private static instance: AppStore; private state: AppState; private listeners: Set<(state: AppState) => void> = new Set(); private quizService: QuizService; private authService: AuthService; private unsubscribeAuth: (() => void) | null = null; private constructor() { this.quizService = QuizService.getInstance(); this.authService = AuthService.getInstance(); this.state = this.getInitialState(); this.loadCompletedQuizzes(); this.setupAuthSubscription(); } static getInstance(): AppStore { if (!AppStore.instance) { AppStore.instance = new AppStore(); } return AppStore.instance; } private getInitialState(): AppState { // Load saved view mode preference const savedViewMode = localStorage.getItem('quiz-view-mode') as ViewMode; const viewMode = savedViewMode === 'single' || savedViewMode === 'list' ? savedViewMode : 'list'; // Initialize settings manager and get default settings const settingsManager = SettingsManager.getInstance(); const settings = settingsManager.getSettings(); // Get initial auth state const authState = this.authService.getAuthState(); return { quizzes: [], currentQuiz: null, filteredQuizzes: [], userAnswers: {}, currentFilter: 'all', searchTerm: '', theme: { mode: 'system', color: 'blue' }, settings, auth: { isAuthenticated: authState.isAuthenticated, user: authState.user, token: authState.token, isLoading: authState.isLoading, error: authState.error, showLoginModal: false, }, ui: { loading: false, showWelcome: true, showResults: false, showCelebration: false, viewMode, submitBarVisible: false, }, quiz: { completedQuizzes: new Set(), quizProgress: {}, isCompleted: false, currentQuestionIndex: 0, }, lastResult: null, }; } getState(): AppState { return { ...this.state }; } subscribe(callback: (state: AppState) => void): () => void { this.listeners.add(callback); return () => { this.listeners.delete(callback); }; } private setState(updates: Partial<AppState>): void { this.state = { ...this.state, ...updates }; this.notifyListeners(); } private notifyListeners(): void { this.listeners.forEach(callback => callback(this.getState())); } // Actions async loadQuizzes(): Promise<void> { this.setState({ ui: { ...this.state.ui, loading: true } }); try { const quizzes = await this.quizService.getAllQuizzes(); this.setState({ quizzes, filteredQuizzes: quizzes, ui: { ...this.state.ui, loading: false, showWelcome: quizzes.length === 0, }, }); } catch (error) { this.setState({ ui: { ...this.state.ui, loading: false } }); throw error; } } selectQuiz(quiz: Quiz): void; selectQuiz(quizId: string): void; selectQuiz(quizOrId: Quiz | string): void { let quiz: Quiz; if (typeof quizOrId === 'string') { const foundQuiz = this.state.quizzes.find(q => q.id === quizOrId); if (!foundQuiz) { throw new Error(`Quiz with ID ${quizOrId} not found`); } quiz = foundQuiz; } else { quiz = quizOrId; } this.setState({ currentQuiz: quiz, userAnswers: {}, ui: { ...this.state.ui, showWelcome: false }, }); } updateAnswer(questionId: string, answer: string): void { this.setState({ userAnswers: { ...this.state.userAnswers, [questionId]: answer, }, }); } setFilter(filter: FilterType): void { this.setState({ currentFilter: filter }); this.applyFilters(); } setSearchTerm(term: string): void { this.setState({ searchTerm: term }); this.applyFilters(); } setViewMode(mode: ViewMode): void { this.setState({ ui: { ...this.state.ui, viewMode: mode }, }); // Save preference to localStorage localStorage.setItem('quiz-view-mode', mode); } private applyFilters(): void { let filtered = [...this.state.quizzes]; // Apply search filter if (this.state.searchTerm) { const term = this.state.searchTerm.toLowerCase(); filtered = filtered.filter( quiz => quiz.title.toLowerCase().includes(term) || (quiz.description && quiz.description.toLowerCase().includes(term)) || (quiz.category && quiz.category.toLowerCase().includes(term)) ); } // Apply category filter switch (this.state.currentFilter) { case 'recent': filtered.sort( (a, b) => new Date(b.updatedAt || b.createdAt || '').getTime() - new Date(a.updatedAt || a.createdAt || '').getTime() ); break; case 'favorites': filtered = filtered.filter(quiz => localStorage.getItem(`favorite-${quiz.id}`) === 'true'); break; } this.setState({ filteredQuizzes: filtered }); } showResults(): void { this.setState({ ui: { ...this.state.ui, showResults: true }, }); } hideResults(): void { this.setState({ ui: { ...this.state.ui, showResults: false }, }); } retakeQuiz(): void { this.setState({ userAnswers: {}, ui: { ...this.state.ui, showResults: false }, }); } // Favorites functionality isFavorite(quizId: string): boolean { return localStorage.getItem(`favorite-${quizId}`) === 'true'; } toggleFavorite(quizId: string): void { const currentValue = this.isFavorite(quizId); localStorage.setItem(`favorite-${quizId}`, (!currentValue).toString()); // Refresh filtered quizzes if we're on favorites view if (this.state.currentFilter === 'favorites') { this.applyFilters(); } } async submitQuiz(): Promise<any> { if (!this.state.currentQuiz) { throw new Error('No quiz selected'); } const totalQuestions = this.state.currentQuiz.questions.length; const answeredQuestions = Object.keys(this.state.userAnswers).length; // Allow partial submissions but warn the user if (answeredQuestions < totalQuestions) { console.warn( `⚠️ Submitting incomplete quiz: ${answeredQuestions}/${totalQuestions} answered` ); // For development/testing, allow partial submissions // In production, you might want to show a confirmation dialog instead if (process.env.NODE_ENV === 'production' && answeredQuestions === 0) { throw new Error('Please answer at least one question before submitting'); } } try { const result = await this.quizService.submitQuiz( this.state.currentQuiz.id, this.state.userAnswers ); // Store the result in state for the ResultsModal to access this.state.lastResult = result; // Mark quiz as completed this.markQuizCompleted(this.state.currentQuiz.id, result); this.showResults(); return result; } catch (error) { throw error; } } /** * Load completed quizzes from localStorage */ private loadCompletedQuizzes(): void { try { const stored = localStorage.getItem('completed-quizzes'); if (stored) { const completedIds = JSON.parse(stored); this.state.quiz.completedQuizzes = new Set(completedIds); } const progressStored = localStorage.getItem('quiz-progress'); if (progressStored) { this.state.quiz.quizProgress = JSON.parse(progressStored); } } catch (error) { console.warn('Failed to load completed quiz data:', error); } } /** * Save completed quizzes to localStorage */ private saveCompletedQuizzes(): void { try { const completedIds = Array.from(this.state.quiz.completedQuizzes); localStorage.setItem('completed-quizzes', JSON.stringify(completedIds)); localStorage.setItem('quiz-progress', JSON.stringify(this.state.quiz.quizProgress)); } catch (error) { console.error('Failed to save completed quiz data:', error); } } /** * Mark a quiz as completed */ markQuizCompleted(quizId: string, result: any): void { this.state.quiz.completedQuizzes.add(quizId); const progress: QuizProgress = { quizId, status: 'completed', answers: this.state.userAnswers, completedAt: new Date(), score: result.score, timeSpent: result.timeSpent, }; this.state.quiz.quizProgress[quizId] = progress; this.state.quiz.isCompleted = true; this.saveCompletedQuizzes(); this.notifyListeners(); console.log(`✅ Quiz completed: ${quizId}`, progress); } /** * Check if a quiz is completed */ isQuizCompleted(quizId: string): boolean { return this.state.quiz.completedQuizzes.has(quizId); } /** * Get quiz progress for a specific quiz */ getQuizProgress(quizId: string): QuizProgress | undefined { return this.state.quiz.quizProgress[quizId]; } /** * Get all completed quiz IDs */ getCompletedQuizIds(): string[] { return Array.from(this.state.quiz.completedQuizzes); } /** * Reset quiz completion state */ resetQuizCompletion(): void { this.state.quiz.isCompleted = false; this.state.quiz.currentQuestionIndex = 0; this.notifyListeners(); } /** * Start a new quiz (reset completion state) */ startQuiz(quiz: Quiz): void { // Validate quiz has questions if (!quiz.questions || !Array.isArray(quiz.questions) || quiz.questions.length === 0) { console.error('Cannot start quiz: missing or invalid questions', quiz); throw new Error('Quiz has no questions available'); } this.selectQuiz(quiz); this.resetQuizCompletion(); // Create or update progress entry const progress: QuizProgress = { quizId: quiz.id, status: 'in-progress', answers: {}, startedAt: new Date(), }; this.state.quiz.quizProgress[quiz.id] = progress; this.saveCompletedQuizzes(); // Auto-hide sidebar on mobile for better UX try { // Import SidebarToggle dynamically to avoid circular dependencies import('../components/SidebarToggle').then(({ SidebarToggle }) => { const sidebarToggle = SidebarToggle.getInstance(); if (sidebarToggle) { sidebarToggle.autoHideOnMobileForQuiz(); } }); } catch (error) { console.warn('Could not auto-hide sidebar on mobile:', error); } console.log(`🚀 Started quiz: ${quiz.id}`); } /** * Show celebration (called before results) */ showCelebration(): void { this.state.ui.showCelebration = true; this.notifyListeners(); // Auto-hide celebration after delay setTimeout(() => { this.state.ui.showCelebration = false; this.notifyListeners(); }, 3000); } /** * Update submit bar visibility */ updateSubmitBarVisibility(): void { const shouldShow = this.state.currentQuiz && !this.state.ui.showWelcome && !this.state.ui.showResults && !this.state.quiz.isCompleted; this.state.ui.submitBarVisible = shouldShow || false; this.notifyListeners(); } /** * Update current question index */ setCurrentQuestionIndex(index: number): void { this.state.quiz.currentQuestionIndex = index; // Update progress if (this.state.currentQuiz) { const progress = this.state.quiz.quizProgress[this.state.currentQuiz.id]; if (progress) { progress.currentQuestion = index; progress.answers = { ...this.state.userAnswers }; this.saveCompletedQuizzes(); } } this.notifyListeners(); } /** * Setup authentication service subscription * * @description Subscribes to auth service state changes and updates app state * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-013 (Auth state synchronization) */ private setupAuthSubscription(): void { this.unsubscribeAuth = this.authService.subscribe(authState => { this.setState({ auth: { ...this.state.auth, isAuthenticated: authState.isAuthenticated, user: authState.user, token: authState.token, isLoading: authState.isLoading, error: authState.error, }, }); }); } /** * Show login modal * * @description Displays the login modal by updating state * * @since 2025-08-04 * @author Claude Code Agent */ showLoginModal(): void { this.setState({ auth: { ...this.state.auth, showLoginModal: true }, }); } /** * Hide login modal * * @description Hides the login modal by updating state * * @since 2025-08-04 * @author Claude Code Agent */ hideLoginModal(): void { this.setState({ auth: { ...this.state.auth, showLoginModal: false }, }); } /** * Authenticate user * * @description Triggers user authentication through auth service * * @param {object} credentials - User login credentials * @returns {Promise<boolean>} Success status * * @since 2025-08-04 * @author Claude Code Agent */ async login(credentials: { username: string; password: string }): Promise<boolean> { return this.authService.login(credentials); } /** * Log out current user * * @description Logs out user through auth service * * @since 2025-08-04 * @author Claude Code Agent */ async logout(): Promise<void> { await this.authService.logout(); } /** * Get current user * * @description Returns current authenticated user * * @returns {User | null} Current user or null * * @since 2025-08-04 * @author Claude Code Agent */ getCurrentUser(): User | null { return this.state.auth.user; } /** * Check if user is authenticated * * @description Returns authentication status * * @returns {boolean} Whether user is authenticated * * @since 2025-08-04 * @author Claude Code Agent */ isAuthenticated(): boolean { return this.state.auth.isAuthenticated; } /** * Make authenticated API request * * @description Helper for making authenticated API requests * * @param {string} url - API endpoint * @param {RequestInit} options - Fetch options * @returns {Promise<Response>} API response * * @since 2025-08-04 * @author Claude Code Agent */ async authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> { return this.authService.authenticatedFetch(url, options); } /** * Clean up subscriptions * * @description Cleans up auth service subscription * * @since 2025-08-04 * @author Claude Code Agent */ destroy(): void { this.unsubscribeAuth?.(); } }