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