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.
364 lines (315 loc) • 11.4 kB
text/typescript
/**
* @moduleName: TimerService
* @version: 1.0.0
* @since: 2025-07-24
* @lastUpdated: 2025-07-24
* @projectSummary: Quiz timing functionality with configurable time limits and pressure mode
* @techStack: TypeScript, DOM API, Local Storage
* @dependency: None
* @interModuleDependency: SettingsService.ts, AppStore.ts
* @requirementsTraceability {@link Requirements.REQ_QUIZ_008} - Quiz timer functionality and time-based features
* @requirementsTraceability {@link Requirements.REQ_UI_006} - Status bar and timer display integration
* @requirementsTraceability {@link Requirements.REQ_CONFIG_002} - Timer configuration and settings management
* @briefDescription: Manages quiz timers, time pressure features, and timing-related UI updates
* @methods: startTimer, stopTimer, pauseTimer, getTimeRemaining, formatTime, isTimerEnabled
* @contributors: GitHub Copilot
* @examples: const timer = TimerService.getInstance(); timer.startTimer(300); // 5 minutes
* @vulnerabilitiesAssessment: No security concerns - client-side timing only, validation on server
*/
export interface TimerSettings {
enabled: boolean;
duration: number; // in seconds
warning: number; // seconds before end to show warning
autoSubmit: boolean; // auto-submit when time expires
}
export interface TimerState {
isRunning: boolean;
timeRemaining: number;
totalTime: number;
isWarning: boolean;
isExpired: boolean;
}
export class TimerService {
private static instance: TimerService;
private timerState: TimerState;
private intervalId: NodeJS.Timeout | null = null;
private callbacks: Array<(state: TimerState) => void> = [];
private timerDisplay: HTMLElement | null = null;
private timerContainer: HTMLElement | null = null;
private warningThreshold: number = 60; // default warning threshold in seconds
private timerToggle: HTMLInputElement | null = null;
private constructor() {
this.timerState = {
isRunning: false,
timeRemaining: 0,
totalTime: 0,
isWarning: false,
isExpired: false,
};
this.initializeDOM();
}
/**
* @description Gets the singleton instance of TimerService
* @returns {TimerService} The singleton TimerService instance
*/
public static getInstance(): TimerService {
if (!TimerService.instance) {
TimerService.instance = new TimerService();
}
return TimerService.instance;
}
private initializeDOM(): void {
this.timerDisplay = document.querySelector('#timer-display');
this.timerContainer = document.querySelector('#quiz-timer');
// Setup timer toggle
this.timerToggle = document.querySelector('#timer-toggle') as HTMLInputElement;
if (this.timerToggle) {
this.timerToggle.addEventListener('change', e => {
const enabled = (e.target as HTMLInputElement).checked;
this.toggleTimer(enabled);
});
}
}
/**
* @description Subscribes to timer state changes
* @param {function} callback Function to call when timer state changes
* @returns {function} Unsubscribe function to remove the callback
*/
public subscribe(callback: (state: TimerState) => void): () => void {
this.callbacks.push(callback);
return () => {
const index = this.callbacks.indexOf(callback);
if (index > -1) {
this.callbacks.splice(index, 1);
}
};
}
private notifySubscribers(): void {
this.callbacks.forEach(callback => callback(this.timerState));
}
/**
* @description Starts the quiz timer with specified duration
* @param {number} durationInSeconds Timer duration in seconds (default: 300)
* @param {number} warningThreshold Warning threshold in seconds (default: 60)
*/
public startTimer(durationInSeconds: number = 300, warningThreshold: number = 60): void {
this.stopTimer(); // Clear any existing timer
this.warningThreshold = warningThreshold;
this.timerState = {
isRunning: true,
timeRemaining: durationInSeconds,
totalTime: durationInSeconds,
isWarning: false,
isExpired: false,
};
this.showTimer();
this.updateDisplay();
this.intervalId = setInterval(() => {
this.tick();
}, 1000);
this.notifySubscribers();
}
/**
* @description Stops the timer and clears all intervals
*/
public stopTimer(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.timerState.isRunning = false;
this.hideTimer();
this.notifySubscribers();
}
/**
* @description Auto-starts timer based on settings (REQ-UI-012) - MODERNIZED
* @param {SettingsService} settingsService Settings service instance
*/
public autoStartIfEnabled(settingsService: any): void {
const settings = settingsService.getSettings();
if (settings.quiz.useTimer) {
console.log('🚀 Auto-starting timer (modernized single setting)');
// Use configured duration or 15-minute default
const timerDuration = settings.quiz.timerDuration || 900; // 15 minutes default
this.startTimer(timerDuration, this.warningThreshold);
}
}
public pauseTimer(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.timerState.isRunning = false;
this.notifySubscribers();
}
public resumeTimer(): void {
if (!this.timerState.isExpired && this.timerState.timeRemaining > 0) {
this.timerState.isRunning = true;
this.intervalId = setInterval(() => {
this.tick();
}, 1000);
this.notifySubscribers();
}
}
private tick(): void {
this.timerState.timeRemaining -= 1;
// Check for warning state using the configurable threshold
this.timerState.isWarning = this.timerState.timeRemaining <= this.warningThreshold;
// Check for expiration
if (this.timerState.timeRemaining <= 0) {
this.timerState.timeRemaining = 0;
this.timerState.isExpired = true;
this.timerState.isRunning = false;
this.stopTimer();
// Auto-submit if enabled
this.handleTimeExpiry();
}
this.updateDisplay();
this.notifySubscribers();
}
private handleTimeExpiry(): void {
// Retrieve TimerSettings from localStorage or a settings service
let autoSubmit = false;
try {
const settingsStr = localStorage.getItem('timerSettings');
if (settingsStr) {
const settings: TimerSettings = JSON.parse(settingsStr);
autoSubmit = settings.autoSubmit;
}
} catch (e) {
// Fallback: do not auto-submit if settings are not available
autoSubmit = false;
}
const submitButton = document.querySelector('#submit-button') as HTMLButtonElement;
if (submitButton) {
// Show time expired message
this.showTimeExpiredMessage();
if (autoSubmit) {
// Auto-submit after a short delay to let user see the message
setTimeout(() => {
submitButton.click();
}, 2000);
}
}
}
private showTimeExpiredMessage(): void {
const message = document.createElement('div');
message.className =
'fixed top-20 left-1/2 transform -translate-x-1/2 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 animate-bounce';
message.textContent =
"⏰ Time's up! " +
(this.getAutoSubmitSetting() ? 'Submitting quiz...' : 'Please submit your quiz.');
document.body.appendChild(message);
setTimeout(() => {
if (document.body.contains(message)) {
document.body.removeChild(message);
}
}, 3000);
}
private getAutoSubmitSetting(): boolean {
try {
const settingsStr = localStorage.getItem('timerSettings');
if (settingsStr) {
const settings: TimerSettings = JSON.parse(settingsStr);
return settings.autoSubmit;
}
} catch (e) {
// Fallback
}
return false;
}
private updateDisplay(): void {
if (this.timerDisplay) {
this.timerDisplay.textContent = this.formatTime(this.timerState.timeRemaining);
// Update styling based on state
if (this.timerState.isWarning) {
this.timerDisplay.classList.add('text-red-500', 'font-bold', 'animate-pulse');
} else {
this.timerDisplay.classList.remove('text-red-500', 'font-bold', 'animate-pulse');
}
}
}
private showTimer(): void {
if (this.timerContainer) {
this.timerContainer.classList.remove('hidden');
this.timerContainer.classList.add('flex');
}
}
private hideTimer(): void {
if (this.timerContainer) {
this.timerContainer.classList.add('hidden');
this.timerContainer.classList.remove('flex');
}
}
private toggleTimer(enabled: boolean): void {
if (enabled) {
// Start timer with default 5 minutes (300 seconds) and default warning threshold
this.startTimer(300, this.warningThreshold);
} else {
this.stopTimer();
}
}
/**
* @description Formats seconds into MM:SS format
* @param {number} seconds Time in seconds to format
* @returns {string} Formatted time string (MM:SS)
*/
public formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
/**
* @description Gets current timer state
* @returns {TimerState} Copy of current timer state
*/
public getState(): TimerState {
return { ...this.timerState };
}
public isTimerEnabled(): boolean {
// MODERNIZED: Remove DOM dependency, use settings service approach if available
// This method is kept for compatibility but should use settings-based logic
return this.timerToggle?.checked || false;
}
public getTimeRemaining(): number {
return this.timerState.timeRemaining;
}
public getProgressPercentage(): number {
if (this.timerState.totalTime === 0) return 0;
return (
((this.timerState.totalTime - this.timerState.timeRemaining) / this.timerState.totalTime) *
100
);
}
// Additional utility methods for enhanced functionality
public setWarningThreshold(seconds: number): void {
this.warningThreshold = seconds;
}
public getWarningThreshold(): number {
return this.warningThreshold;
}
public saveTimerSettings(settings: TimerSettings): void {
try {
localStorage.setItem('timerSettings', JSON.stringify(settings));
} catch (e) {
console.warn('Failed to save timer settings:', e);
}
}
public loadTimerSettings(): TimerSettings {
try {
const settingsStr = localStorage.getItem('timerSettings');
if (settingsStr) {
return JSON.parse(settingsStr);
}
} catch (e) {
console.warn('Failed to load timer settings:', e);
}
// Return default settings
return {
enabled: false,
duration: 300, // 5 minutes
warning: 60, // 1 minute warning
autoSubmit: false,
};
}
}