diff --git a/client/private/Advanced/Advance.js b/client/private/Advanced/Advance.js index a8767db..05bd83e 100644 --- a/client/private/Advanced/Advance.js +++ b/client/private/Advanced/Advance.js @@ -1,8 +1 @@ -function transferProblem(elem) { - const parent = elem.parentElement; - const title = parent.querySelector('h3').textContent; - const description = parent.querySelector('p').textContent; - localStorage.setItem('problemTitle', title); - localStorage.setItem('problemDescription', description); - window.location.href = window.location.origin + "/private/CoderPage/coder.html"; -} \ No newline at end of file +// Mobile menu toggle functionality \ No newline at end of file diff --git a/client/private/Arena/modules/ArenaCore.js b/client/private/Arena/modules/ArenaCore.js index c499c7e..d4a1544 100644 --- a/client/private/Arena/modules/ArenaCore.js +++ b/client/private/Arena/modules/ArenaCore.js @@ -125,7 +125,7 @@ class ArenaCore { async loadArenaStats() { try { - const response = await fetch('/api/arena/stats'); + const response = await fetch('/api/arena/system-stats'); if (response.ok) { const { stats } = await response.json(); this.uiManager.updateArenaStats(stats); diff --git a/client/private/Arena/modules/ArenaUIManager.js b/client/private/Arena/modules/ArenaUIManager.js index b9050ee..57a54ae 100644 --- a/client/private/Arena/modules/ArenaUIManager.js +++ b/client/private/Arena/modules/ArenaUIManager.js @@ -70,9 +70,9 @@ class ArenaUIManager { } updateArenaStats(stats) { - document.getElementById('onlineUsers').textContent = stats.onlineUsers; - document.getElementById('activeMatches').textContent = stats.activeMatches; - document.getElementById('totalMatches').textContent = stats.totalMatches; + document.getElementById('onlineUsers').textContent = stats.onlinePlayersCount || 0; + document.getElementById('activeMatches').textContent = stats.activeMatchesCount || 0; + document.getElementById('totalMatches').textContent = stats.totalMatchesCount || 0; } updatePlayerStats(stats) { diff --git a/client/private/CoderPage/coder.js b/client/private/CoderPage/coder.js index 6f7a4ad..2747e3c 100644 --- a/client/private/CoderPage/coder.js +++ b/client/private/CoderPage/coder.js @@ -12,7 +12,6 @@ window.addEventListener('load', async () => { // Mobile menu toggle functionality const menuToggle = document.getElementById('menu-toggle'); -const closeBtn = document.getElementById('close-btn'); const sidebar = document.getElementById('sidebar'); const outputBox = document.getElementById('output-box'); @@ -262,6 +261,7 @@ function displayProblem(problem) { }, 500); // Small delay to ensure DOM is ready } +// eslint-disable-next-line no-unused-vars async function runCode(btn) { btn.textContent = 'Running...'; btn.disabled = true; @@ -328,6 +328,7 @@ async function runCode(btn) { } // Submit solution function +// eslint-disable-next-line no-unused-vars async function submitSolution(btn) { if (!currentProblem) { alert('No problem loaded. Please try refreshing the page.'); @@ -365,18 +366,29 @@ async function submitSolution(btn) { if (data.success) { displaySubmissionResults(data.submission); - // AI assistance for failed test cases (Easy problems only) - if (aiAssistanceInstance && currentProblem && currentProblem.difficulty === 'easy' && - data.submission.status !== 'accepted' && data.submission.testResults) { + // Handle successful test completion - disable AI assistance if all tests pass + if (data.submission.status === 'accepted') { + console.log('🎉 All tests passed! Disabling AI assistance...'); - // Analyze failed test cases with AI - aiAssistanceInstance.analyzeTestCaseFailure({ - status: data.submission.status, - testCasesPassed: data.submission.testCasesPassed, - totalTestCases: data.submission.totalTestCases, - testResults: data.submission.testResults.filter(test => !test.passed), - feedback: data.submission.feedback - }); + // Disable AI assistance since the problem is solved correctly + if (aiAssistanceInstance) { + aiAssistanceInstance.disableForSuccess(); + console.log('✅ AI assistance disabled - problem solved successfully!'); + } + } else { + // AI assistance for failed test cases (Easy problems only) + if (aiAssistanceInstance && currentProblem && currentProblem.difficulty === 'easy' && + data.submission.testResults) { + + // Analyze failed test cases with AI + aiAssistanceInstance.analyzeTestCaseFailure({ + status: data.submission.status, + testCasesPassed: data.submission.testCasesPassed, + totalTestCases: data.submission.totalTestCases, + testResults: data.submission.testResults.filter(test => !test.passed), + feedback: data.submission.feedback + }); + } } // Handle different solve scenarios @@ -836,6 +848,8 @@ function displaySubmissionResults(submission) { outputBox.className = isAccepted ? 'output-success' : 'output-error'; } +// Language change function +// eslint-disable-next-line no-unused-vars function changeLang(list) { const selectedLang = list.value; diff --git a/client/private/Easy/beginer.js b/client/private/Easy/beginer.js index 7b9d32c..4928fca 100644 --- a/client/private/Easy/beginer.js +++ b/client/private/Easy/beginer.js @@ -1,6 +1,5 @@ // Mobile menu toggle functionality const menuToggle = document.getElementById('menu-toggle'); -const closeBtn = document.getElementById('close-btn'); const sidebar = document.getElementById('sidebar'); menuToggle.addEventListener('click', (e) => { @@ -9,11 +8,6 @@ menuToggle.addEventListener('click', (e) => { document.body.classList.toggle('no-scroll'); }); -closeBtn.addEventListener('click', () => { - sidebar.classList.remove('active'); - document.body.classList.remove('no-scroll'); -}); - // Close sidebar when clicking anywhere except the sidebar itself document.addEventListener('click', (e) => { // Check if the click is outside the sidebar and the menu toggle button @@ -28,11 +22,4 @@ sidebar.addEventListener('click', (e) => { e.stopPropagation(); }); -function transferProblem(elem) { - const parent = elem.parentElement; - const title = parent.querySelector('h3').textContent; - const description = parent.querySelector('p').textContent; - localStorage.setItem('problemTitle', title); - localStorage.setItem('problemDescription', description); - window.location.href = window.location.origin + "/private/CoderPage/coder.html"; -} \ No newline at end of file +// Mobile menu toggle functionality \ No newline at end of file diff --git a/client/private/HomePage/codigo.html b/client/private/HomePage/codigo.html index ac5ad78..4203194 100644 --- a/client/private/HomePage/codigo.html +++ b/client/private/HomePage/codigo.html @@ -9,6 +9,12 @@ + + + @@ -29,7 +35,7 @@ - Logout + @@ -75,7 +81,7 @@

📊 Quick stats

@@ -460,6 +466,7 @@

Contact

}); })(); + diff --git a/client/private/HomePage/script.js b/client/private/HomePage/script.js index 9315508..c577439 100644 --- a/client/private/HomePage/script.js +++ b/client/private/HomePage/script.js @@ -1,16 +1,7 @@ -// @author Rouvik Maji +// @author Rouvik Maji & Archisman Pal async function updateUserDetails() { - const username = document.getElementById("dashboard_username"); - const currRank = document.getElementById("dashboard_currRank"); - const contestCount = document.getElementById("dashboard_contestCount"); - const streakCount = document.getElementById("dashboard_streakCount"); - - // New detailed stats elements - const totalProblems = document.getElementById("dashboard_totalProblems"); - const easyCount = document.getElementById("dashboard_easyCount"); - const mediumCount = document.getElementById("dashboard_mediumCount"); - const hardCount = document.getElementById("dashboard_hardCount"); + // DOM elements are accessed in updateStatsDisplay function try { // Check if stats were updated from localStorage first @@ -29,18 +20,40 @@ async function updateUserDetails() } } - // Otherwise fetch from server - const res = await fetch(window.location.origin + '/api/userdata'); - const data = await res.json(); + // Fetch user basic data + const userResponse = await fetch('/api/userdata'); - updateStatsDisplay(data); + // Fetch detailed user stats + const statsResponse = await fetch('/api/user/stats'); + + if (userResponse.ok && statsResponse.ok) { + const userData = await userResponse.json(); + const statsData = await statsResponse.json(); + + // Combine the data - use rankPosition for display instead of rank points + const combinedData = { + name: userData.name || 'User', + rank: statsData.stats.rankPosition || 0, // Use actual leaderboard position + arenaWins: statsData.stats.arena?.wins || 0, // Arena matches won + streak_count: statsData.stats.streak_count || 0, + problemsSolved: statsData.stats.problemsSolved || 0, + easyCount: statsData.stats.easyCount || 0, + mediumCount: statsData.stats.mediumCount || 0, + hardCount: statsData.stats.hardCount || 0, + realWorldCount: statsData.stats.realWorldCount || 0 + }; + + updateStatsDisplay(combinedData); + } else { + throw new Error('Failed to fetch user data or stats'); + } } catch (error) { console.error('Failed to update user details:', error); // Set default values if fetch fails updateStatsDisplay({ name: 'User', rank: 0, - contests_count: 0, + arenaWins: 0, // Default arena wins to 0 streak_count: 0, problemsSolved: 0, easyCount: 0, @@ -63,7 +76,7 @@ function updateStatsDisplay(data) { username.textContent = data.name || 'User'; currRank.textContent = data.rank || '0'; - contestCount.textContent = data.contests_count || '0'; + contestCount.textContent = data.arenaWins || '0'; // Display arena wins instead of contests streakCount.textContent = data.streak_count || '0'; // Update detailed problem stats @@ -152,8 +165,8 @@ function showStatsUpdateNotification(stats) { // Arena Data Loading Functions async function loadArenaData() { try { - // Load Arena stats - const statsResponse = await fetch('/api/arena/stats'); + // Load Arena system stats (online users, active matches, total matches) + const statsResponse = await fetch('/api/arena/system-stats'); if (statsResponse.ok) { const { stats } = await statsResponse.json(); updateArenaStats(stats); @@ -171,9 +184,9 @@ async function loadArenaData() { } function updateArenaStats(stats) { - document.getElementById('arenaOnlineUsers').textContent = stats.onlineUsers || 0; - document.getElementById('arenaActiveMatches').textContent = stats.activeMatches || 0; - document.getElementById('arenaTotalMatches').textContent = stats.totalMatches || 0; + document.getElementById('arenaOnlineUsers').textContent = stats.onlinePlayersCount || 0; + document.getElementById('arenaActiveMatches').textContent = stats.activeMatchesCount || 0; + document.getElementById('arenaTotalMatches').textContent = stats.totalMatchesCount || 0; } function updateArenaLeaderboard(leaderboard) { @@ -198,8 +211,6 @@ function updateArenaLeaderboard(leaderboard) { `).join(''); } - -// Add Arena data loading to the existing initialization document.addEventListener('DOMContentLoaded', function() { // 1. Navigation Toggle const nav = document.querySelector('.codingPageNav'); @@ -382,12 +393,4 @@ function success() }); updateUserDetails(); // update the user details once all listeners have been added - // Load Arena data when the page loads - loadArenaData(); - - // Refresh Arena data every 30 seconds - setInterval(loadArenaData, 30000); }); - -// Make loadArenaData globally available for the refresh button -window.loadArenaData = loadArenaData; diff --git a/client/private/Intermediate/interemdiate.js b/client/private/Intermediate/interemdiate.js index 7b9d32c..4928fca 100644 --- a/client/private/Intermediate/interemdiate.js +++ b/client/private/Intermediate/interemdiate.js @@ -1,6 +1,5 @@ // Mobile menu toggle functionality const menuToggle = document.getElementById('menu-toggle'); -const closeBtn = document.getElementById('close-btn'); const sidebar = document.getElementById('sidebar'); menuToggle.addEventListener('click', (e) => { @@ -9,11 +8,6 @@ menuToggle.addEventListener('click', (e) => { document.body.classList.toggle('no-scroll'); }); -closeBtn.addEventListener('click', () => { - sidebar.classList.remove('active'); - document.body.classList.remove('no-scroll'); -}); - // Close sidebar when clicking anywhere except the sidebar itself document.addEventListener('click', (e) => { // Check if the click is outside the sidebar and the menu toggle button @@ -28,11 +22,4 @@ sidebar.addEventListener('click', (e) => { e.stopPropagation(); }); -function transferProblem(elem) { - const parent = elem.parentElement; - const title = parent.querySelector('h3').textContent; - const description = parent.querySelector('p').textContent; - localStorage.setItem('problemTitle', title); - localStorage.setItem('problemDescription', description); - window.location.href = window.location.origin + "/private/CoderPage/coder.html"; -} \ No newline at end of file +// Mobile menu toggle functionality \ No newline at end of file diff --git a/client/private/Leaderboard/leaderboard.html b/client/private/Leaderboard/leaderboard.html index 106b7bf..e40bdeb 100644 --- a/client/private/Leaderboard/leaderboard.html +++ b/client/private/Leaderboard/leaderboard.html @@ -10,6 +10,12 @@ + + + @@ -20,9 +26,9 @@ diff --git a/client/private/Leaderboard/leaderboard.js b/client/private/Leaderboard/leaderboard.js index f63ae5d..e31a579 100644 --- a/client/private/Leaderboard/leaderboard.js +++ b/client/private/Leaderboard/leaderboard.js @@ -115,8 +115,11 @@ function renderLeaderboard(leaderboard) { return; } + // The backend now properly sorts and assigns rankPosition, so we can trust the order leaderboard.forEach((user, index) => { - const row = createLeaderboardRow(user, index + 1); + // Use the rankPosition from backend (which handles ties properly) + const position = user.rankPosition || (index + 1); + const row = createLeaderboardRow(user, position); tableBody.appendChild(row); }); } @@ -131,11 +134,14 @@ function createLeaderboardRow(user, position) { // Format last solve date const lastSolve = user.lastSolvedDate ? formatDate(user.lastSolvedDate) : 'Never'; + // Use the rankPosition from the backend if available, otherwise use the frontend position + const displayPosition = user.rankPosition || position; + // Add special styling for top 3 - const rankClass = position <= 3 ? `rank-${position} top-3` : ''; + const rankClass = displayPosition <= 3 ? `rank-${displayPosition} top-3` : ''; row.innerHTML = ` -
${position}
+
${displayPosition}
${initials}
${user.name || 'Anonymous'}
diff --git a/client/private/Real-World/realWorldLoader.js b/client/private/Real-World/realWorldLoader.js index 27c9fca..9d5b520 100644 --- a/client/private/Real-World/realWorldLoader.js +++ b/client/private/Real-World/realWorldLoader.js @@ -12,7 +12,10 @@ class RealWorldProjectLoader extends ProblemLoader { let url = '/api/problems'; const params = new URLSearchParams(); - // For real-world page, we want all difficulties but specific categories + // For real-world page, fetch problems with "real-world" difficulty + params.append('difficulty', 'real-world'); + + // Optionally filter by category as well if (category && category !== 'all') { params.append('category', category); } @@ -25,10 +28,8 @@ class RealWorldProjectLoader extends ProblemLoader { const data = await response.json(); if (data.success) { - // Filter for real-world project categories - this.problems = data.problems.filter(problem => - ['games', 'web', 'ai', 'algorithms'].includes(problem.category) - ); + // Return all real-world problems (they should already be filtered by difficulty) + this.problems = data.problems; return this.problems; } else { throw new Error(data.error || 'Failed to fetch problems'); @@ -92,7 +93,8 @@ class RealWorldProjectLoader extends ProblemLoader { const difficultyColor = { 'easy': '#28a745', 'medium': '#ffc107', - 'hard': '#dc3545' + 'hard': '#dc3545', + 'real-world': '#6c5ce7' // Purple color for real-world projects }; const categoryIcons = { diff --git a/client/private/common/aiAssistance.js b/client/private/common/aiAssistance.js index 544a54c..043a07e 100644 --- a/client/private/common/aiAssistance.js +++ b/client/private/common/aiAssistance.js @@ -292,79 +292,6 @@ class AIAssistanceManager { } } - // Remove old panel-based methods since we're using VS Code-like popup approach - displaySuggestions() { - // This method is no longer needed - suggestions are shown via lightbulb popup - } - - displayErrors() { - // This method is no longer needed - errors are shown via lightbulb popup - } - - displayWarnings() { - // This method is no longer needed - warnings are shown via lightbulb popup - } - - async loadLanguageTips() { - // Language tips can be shown in popup when relevant - try { - const response = await fetch(`/api/ai/language-tips/${this.language}`); - const data = await response.json(); - - if (data.success) { - this.currentLanguageTips = data.tips; - } - } catch (error) { - console.error('Failed to load language tips:', error); - } - } - - showThinking() { - // Update status indicator to show AI is thinking - const statusText = document.querySelector('.ai-status-text'); - if (statusText) { - statusText.textContent = 'AI Assistant Analyzing...'; - setTimeout(() => { - statusText.textContent = 'AI Assistant Active'; - }, 3000); - } - } - - showError(message) { - console.error('AI Assistant Error:', message); - const statusText = document.querySelector('.ai-status-text'); - if (statusText) { - statusText.textContent = 'AI Assistant Error'; - setTimeout(() => { - statusText.textContent = 'AI Assistant Active'; - }, 5000); - } - } - - toggleSection() { - // No longer needed with popup approach - } - - toggleAssistance() { - this.isEnabled = !this.isEnabled; - const indicator = document.getElementById('ai-status-indicator'); - const toggleBtn = document.getElementById('ai-toggle'); - - if (indicator) { - indicator.classList.toggle('disabled', !this.isEnabled); - } - - if (toggleBtn) { - toggleBtn.querySelector('.toggle-icon').textContent = this.isEnabled ? '👁️' : '🙈'; - toggleBtn.title = this.isEnabled ? 'Disable AI Assistance' : 'Enable AI Assistance'; - } - - // Clear existing decorations when disabled - if (!this.isEnabled) { - this.clearLineDecorations(); - } - } - setupEventListeners() { // Toggle AI assistance panel const toggleBtn = document.getElementById('ai-toggle'); @@ -378,6 +305,12 @@ class AIAssistanceManager { if (this.codeEditor) { // Real-time analysis (fast response for per-line help) this.codeEditor.on('change', () => { + // Re-enable AI assistance if it was disabled due to successful solve + // but user is now modifying the code (maybe to improve it) + if (!this.isEnabled) { + this.reEnableAfterModification(); + } + clearTimeout(this.realTimeTimer); this.realTimeTimer = setTimeout(() => { this.performRealTimeAnalysis(); @@ -623,7 +556,7 @@ class AIAssistanceManager { async performRealTimeAnalysis() { if (!this.isEnabled) { - console.log('🚫 AI assistance is disabled'); + console.log('🚫 AI assistance is disabled (problem solved or manually disabled)'); return; } @@ -636,6 +569,12 @@ class AIAssistanceManager { return; } + // Smart validation: Don't analyze simple, complete solutions + if (this.isSimpleCompleteSolution(code)) { + console.log('🎯 Code appears to be a complete, simple solution - skipping unnecessary analysis'); + return; + } + // Smart language detection based on code content const detectedLanguage = window.detectLanguageFromCode ? window.detectLanguageFromCode(code) : this.language; if (detectedLanguage !== this.language) { @@ -662,6 +601,15 @@ class AIAssistanceManager { problem: this.currentProblem }; + console.log('🔍 Making real-time analysis request:', { + url: '/api/ai/real-time-analysis', + method: 'POST', + currentLine: currentLine, + language: this.language, + codeLength: code.length, + windowLocation: window.location.href + }); + const response = await fetch('/api/ai/real-time-analysis', { method: 'POST', headers: { @@ -670,13 +618,46 @@ class AIAssistanceManager { body: JSON.stringify(requestData) }); + if (!response.ok) { + console.error('❌ AI analysis request failed:', { + status: response.status, + statusText: response.statusText, + url: response.url, + headers: Object.fromEntries(response.headers.entries()) + }); + const errorText = await response.text(); + console.error('Error response body:', errorText); + + // Handle authentication errors specifically + if (response.status === 401 || response.status === 403) { + console.warn('🔐 Authentication required for AI assistance'); + this.showAuthenticationRequired(); + } + return; + } + const data = await response.json(); + console.log('✅ AI analysis response:', data); if (data.success) { this.updateLineDecorations(data.analysis); + } else { + console.warn('⚠️ AI analysis unsuccessful:', data.error); } } catch (error) { - console.error('Real-time analysis error:', error); + console.error('❌ Real-time analysis error:', error); + console.error('Error details:', { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause + }); + + // Check if it's a network error + if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) { + console.error('🌐 Network error - check if server is running and accessible'); + this.showNetworkError(); + } } } @@ -1120,6 +1101,56 @@ class AIAssistanceManager { } } + /** + * Smart validation to avoid unnecessary suggestions for simple, complete solutions + * @param {string} code - The complete code + * @returns {boolean} True if this appears to be a simple, complete solution + */ + isSimpleCompleteSolution(code) { + const lines = code.trim().split('\n').filter(line => line.trim()); + + // Very short solutions (1-3 lines) that follow common patterns + if (lines.length <= 3) { + const codeText = code.toLowerCase(); + + // Common simple input/output patterns that are likely correct + const simplePatterns = [ + // Python: a, b = map(int, input().split()); print(a + b) + /^\s*[a-z],\s*[a-z]\s*=\s*map\s*\(\s*int\s*,\s*input\s*\(\s*\)\s*\.\s*split\s*\(\s*\)\s*\)\s*$/, + // Python: print(a + b) or similar + /^\s*print\s*\(\s*[a-z]\s*[+\-*/]\s*[a-z]\s*\)\s*$/, + // Python: result = a + b; print(result) + /^\s*[a-z_]+\s*=\s*[a-z]\s*[+\-*/]\s*[a-z]\s*$/, + // JavaScript: console.log(a + b) + /^\s*console\s*\.\s*log\s*\(\s*[a-z]\s*[+\-*/]\s*[a-z]\s*\)\s*$/, + // Simple variable declarations and operations + /^\s*(let|const|var)\s+[a-z_]+\s*=\s*.+$/ + ]; + + // Check if the code matches simple, working patterns + for (const line of lines) { + for (const pattern of simplePatterns) { + if (pattern.test(line)) { + return true; + } + } + } + + // Additional checks for common complete solutions + if (codeText.includes('input') && codeText.includes('print') && lines.length <= 2) { + return true; // Likely a simple input/output solution + } + + if ((codeText.includes('console.log') || codeText.includes('print')) && + (codeText.includes('+') || codeText.includes('-') || codeText.includes('*') || codeText.includes('/')) && + lines.length <= 3) { + return true; // Likely a simple calculation solution + } + } + + return false; + } + // Clean up method for proper disposal destroy() { // Clear timers @@ -1323,6 +1354,96 @@ class AIAssistanceManager { } }, 10000); } + + /** + * Disable AI assistance when the user successfully passes all tests + * This prevents unnecessary suggestions when the problem is already solved correctly + */ + disableForSuccess() { + console.log('🎉 Disabling AI assistance - all tests passed!'); + + // Clear all existing decorations and suggestions + this.clearLineDecorations(); + this.removeExistingPopups(); + + // Update status to show success + const statusText = document.querySelector('.ai-status-text'); + const statusIndicator = document.getElementById('ai-status-indicator'); + + if (statusText) { + statusText.textContent = 'AI Assistant - Problem Solved! 🎉'; + statusText.style.color = '#75f74d'; + } + + if (statusIndicator) { + statusIndicator.classList.add('success-state'); + } + + // Disable further analysis + this.isEnabled = false; + + // Add success state styling + this.addSuccessStateStyles(); + + console.log('✅ AI assistance successfully disabled for solved problem'); + } + + /** + * Add CSS styles for success state + */ + addSuccessStateStyles() { + const existingStyles = document.getElementById('ai-assistance-styles'); + if (existingStyles) { + const successStyles = ` + /* Success state styling */ + .ai-status-indicator.success-state { + background: linear-gradient(135deg, #75f74d 0%, #2b8a3e 100%); + border-color: #75f74d; + } + + .ai-status-indicator.success-state .ai-status-icon { + animation: celebrateSuccess 2s ease-in-out; + } + + @keyframes celebrateSuccess { + 0%, 100% { transform: scale(1); } + 25% { transform: scale(1.2) rotate(10deg); } + 50% { transform: scale(1.1) rotate(-10deg); } + 75% { transform: scale(1.2) rotate(5deg); } + } + + .ai-status-indicator.success-state .ai-status-text { + color: #000 !important; + font-weight: 600; + } + `; + + existingStyles.insertAdjacentHTML('beforeend', successStyles); + } + } + + /** + * Re-enable AI assistance (e.g., when user modifies code after solving) + */ + reEnableAfterModification() { + if (!this.isEnabled) { + console.log('🔄 Re-enabling AI assistance - code modified after solve'); + this.isEnabled = true; + + // Update status back to normal + const statusText = document.querySelector('.ai-status-text'); + const statusIndicator = document.getElementById('ai-status-indicator'); + + if (statusText) { + statusText.textContent = 'AI Assistant Active'; + statusText.style.color = ''; + } + + if (statusIndicator) { + statusIndicator.classList.remove('success-state'); + } + } + } } // Export for module usage diff --git a/client/private/common/auth.js b/client/private/common/auth.js index 18e5a29..09dd1a4 100644 --- a/client/private/common/auth.js +++ b/client/private/common/auth.js @@ -75,6 +75,26 @@ class AuthManager { } }; } + + async logout() { + try { + // Check if Clerk is loaded and sign out the user + if (typeof Clerk !== 'undefined' && Clerk.user) { + await Clerk.signOut(); + } + + // Clear local auth state + this.isAuthenticated = false; + this.user = null; + + // Redirect to landing page + window.location.href = '/public/LandingPage/index.html'; + } catch (error) { + console.error('Logout failed:', error); + // Even if Clerk logout fails, redirect to landing page + window.location.href = '/public/LandingPage/index.html'; + } + } } // Initialize global auth manager diff --git a/client/private/common/problemLoader.js b/client/private/common/problemLoader.js index b52fa12..4a95daa 100644 --- a/client/private/common/problemLoader.js +++ b/client/private/common/problemLoader.js @@ -153,11 +153,12 @@ class ProblemLoader { total: problems.length, easy: problems.filter(p => p.difficulty === 'easy').length, medium: problems.filter(p => p.difficulty === 'medium').length, - hard: problems.filter(p => p.difficulty === 'hard').length + hard: problems.filter(p => p.difficulty === 'hard').length, + 'real-world': problems.filter(p => p.difficulty === 'real-world').length }; } catch (error) { console.error('Error getting problem stats:', error); - return { total: 0, easy: 0, medium: 0, hard: 0 }; + return { total: 0, easy: 0, medium: 0, hard: 0, 'real-world': 0 }; } } } @@ -171,7 +172,27 @@ document.addEventListener('DOMContentLoaded', function() { const currentPage = window.location.pathname; if (currentPage.includes('beginner') || currentPage.includes('Easy')) { - problemLoader.renderProblems('challenge-container', 'easy'); + // For beginner page, load easy problems but exclude games (those belong in Real-World) + problemLoader.fetchProblems('easy').then(problems => { + const filteredProblems = problems.filter(problem => problem.category !== 'games'); + problemLoader.problems = filteredProblems; + + const container = document.getElementById('challenge-container'); + if (container) { + if (filteredProblems.length === 0) { + container.innerHTML = '
No problems found for this difficulty.
'; + return; + } + const problemsHTML = filteredProblems.map(problem => problemLoader.createProblemHTML(problem)).join(''); + container.innerHTML = problemsHTML; + problemLoader.addProblemClickHandlers(); + } + }).catch(error => { + const container = document.getElementById('challenge-container'); + if (container) { + container.innerHTML = `
Error loading problems: ${error.message}
`; + } + }); } else if (currentPage.includes('Intermediate')) { problemLoader.renderProblems('challenge-container', 'medium'); } else if (currentPage.includes('Advanced')) { diff --git a/client/public/LandingPage/script.js b/client/public/LandingPage/script.js index b4b3ef9..0bd84f1 100644 --- a/client/public/LandingPage/script.js +++ b/client/public/LandingPage/script.js @@ -15,6 +15,7 @@ function updateLoginButtonText() { updateLoginButtonText(); window.addEventListener("resize", updateLoginButtonText); +// eslint-disable-next-line no-unused-vars function toggleFAQ(element) { const faq = element.parentElement; faq.classList.toggle("open"); diff --git a/package.json b/package.json index 3402752..3922133 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node server/index.js", "dev": "nodemon server/index.js", + "test": "echo \"No tests specified yet\" && exit 0", "seed": "node server/seedDatabase.js", "lint": "eslint server/ client/ --ext .js --max-warnings 50", @@ -22,7 +23,7 @@ "ws", "dotenv" ], - "author": "Rouvik Maji", + "author": "Rouvik Maji Archisman Pal", "license": "GPL-3.0-or-later", "bugs": { "url": "https://github.com/vikashgupta16/CodeBattle-Arena/issues" diff --git a/server/aiAssistance.js b/server/aiAssistance.js deleted file mode 100644 index 4dd4e85..0000000 --- a/server/aiAssistance.js +++ /dev/null @@ -1,529 +0,0 @@ -import { GoogleGenerativeAI } from '@google/generative-ai'; -import dotenv from 'dotenv'; - -dotenv.config(); - -class AIAssistanceService { - constructor() { - this.genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - this.model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); // Updated to Gemini 2.0 Flash - this.isEnabled = process.env.AI_ASSISTANCE_ENABLED === 'true'; - } - - /** - * Analyze code line by line and provide suggestions - * @param {string} code - The code to analyze - * @param {string} language - Programming language - * @param {Object} problem - Problem context - * @param {number} currentLine - Current line being analyzed (0-indexed) - * @returns {Object} Analysis result with suggestions - */ - async analyzeCodeLine(code, language, problem, currentLine = null) { - if (!this.isEnabled) { - return { suggestions: [], errors: [], warnings: [] }; - } - - try { - const lines = code.split('\n'); - const currentLineCode = currentLine !== null ? lines[currentLine] : ''; - - const prompt = ` -You are an AI coding assistant for beginner programmers. Analyze this ${language} code and provide helpful suggestions. - -PROBLEM CONTEXT: -Title: ${problem?.title || 'Unknown'} -Description: ${problem?.description || 'No description'} -Difficulty: ${problem?.difficulty || 'beginner'} - -CURRENT CODE: -\`\`\`${language} -${code} -\`\`\` - -${currentLine !== null ? `CURRENT LINE (${currentLine + 1}): ${currentLineCode}` : ''} - -Please provide a JSON response with: -1. "suggestions" - Array of helpful coding suggestions for beginners -2. "errors" - Array of syntax or logic errors found -3. "warnings" - Array of potential issues or improvements -4. "lineSpecific" - Suggestions specific to the current line (if provided) - -Format each item as: { "line": number, "type": "error|warning|suggestion", "message": "description", "fix": "suggested fix" } - -Focus on: -- Syntax errors -- Common beginner mistakes -- Logic issues -- Best practices for beginners -- Variable naming conventions -- Missing imports or declarations -- Infinite loops or inefficient code - -Keep suggestions beginner-friendly and encouraging! -`; - - const result = await this.model.generateContent(prompt); - const response = await result.response; - const text = response.text(); - - // Try to parse JSON response - try { - const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/) || text.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]); - return this.formatResponse(parsed); - } - } catch (parseError) { - console.warn('Could not parse AI response as JSON, providing fallback'); - } - - // Fallback: extract suggestions from text - return this.extractSuggestionsFromText(text, currentLine); - - } catch (error) { - console.error('AI Assistance Error:', error); - return { - suggestions: [], - errors: [], - warnings: [], - error: 'AI assistance temporarily unavailable' - }; - } - } - - /** - * Get real-time code completion suggestions - * @param {string} code - Current code - * @param {string} language - Programming language - * @param {number} cursorLine - Line where cursor is - * @param {number} cursorColumn - Column where cursor is - * @returns {Array} Array of completion suggestions - */ - async getCodeCompletion(code, language, cursorLine, cursorColumn) { - if (!this.isEnabled) return []; - - try { - const lines = code.split('\n'); - const currentLine = lines[cursorLine] || ''; - const beforeCursor = currentLine.substring(0, cursorColumn); - - const prompt = ` -You are providing code completion for a beginner ${language} programmer. - -CURRENT CODE: -\`\`\`${language} -${code} -\`\`\` - -CURSOR POSITION: Line ${cursorLine + 1}, Column ${cursorColumn + 1} -CURRENT LINE: ${currentLine} -BEFORE CURSOR: ${beforeCursor} - -Provide 3-5 relevant code completion suggestions as a JSON array: -[ - { - "text": "completion text", - "description": "what this does", - "type": "function|variable|keyword|method" - } -] - -Focus on: -- Common ${language} patterns -- Beginner-friendly suggestions -- Context-aware completions -- Built-in functions and methods -`; - - const result = await this.model.generateContent(prompt); - const response = await result.response; - const text = response.text(); - - try { - const jsonMatch = text.match(/\[[\s\S]*?\]/); - if (jsonMatch) { - return JSON.parse(jsonMatch[0]); - } - } catch (parseError) { - console.warn('Could not parse completion suggestions'); - } - - return []; - - } catch (error) { - console.error('Code Completion Error:', error); - return []; - } - } - - /** - * Analyze code for specific error and provide fix suggestion - * @param {string} code - Code with error - * @param {string} language - Programming language - * @param {string} errorMessage - Error message from execution - * @returns {Object} Fix suggestion - */ - async suggestFix(code, language, errorMessage) { - if (!this.isEnabled) return null; - - try { - const prompt = ` -You are helping a beginner fix their ${language} code. - -CODE: -\`\`\`${language} -${code} -\`\`\` - -ERROR MESSAGE: ${errorMessage} - -Provide a JSON response with: -{ - "explanation": "Simple explanation of what went wrong", - "fix": "Suggested code fix", - "line": "Line number where fix should be applied", - "example": "Example of corrected code snippet" -} - -Make the explanation beginner-friendly and encouraging! -`; - - const result = await this.model.generateContent(prompt); - const response = await result.response; - const text = response.text(); - - try { - const jsonMatch = text.match(/\{[\s\S]*?\}/); - if (jsonMatch) { - return JSON.parse(jsonMatch[0]); - } - } catch (parseError) { - console.warn('Could not parse fix suggestion'); - } - - return null; - - } catch (error) { - console.error('Fix Suggestion Error:', error); - return null; - } - } - - /** - * Real-time analysis for per-line assistance - * @param {string} code - Complete code - * @param {number} currentLine - Current line number - * @param {string} currentLineText - Text of current line - * @param {string} language - Programming language - * @param {Object} problem - Problem context - * @returns {Object} Real-time analysis with line-specific suggestions - */ - async performRealTimeAnalysis(code, currentLine, currentLineText, language, problem) { - if (!this.isEnabled) { - return { lineAnalysis: [] }; - } - - try { - const prompt = ` -You are a real-time AI coding assistant providing VS Code-like IntelliSense. Analyze this code and provide immediate, actionable suggestions. - -PROBLEM: ${problem?.title || 'Coding Challenge'} -LANGUAGE: ${language} -CURRENT LINE ${currentLine + 1}: "${currentLineText}" - -FULL CODE: -\`\`\`${language} -${code} -\`\`\` - -Provide a JSON response with: -{ - "lineAnalysis": [ - { - "line": , - "type": "suggestion|error|warning", - "message": "", - "fix": "", - "severity": "high|medium|low" - } - ], - "currentLineSuggestions": [ - { - "message": "", - "code": "", - "explanation": "" - } - ] -} - -Focus on: -- Syntax errors and typos -- Missing semicolons, brackets, parentheses -- Variable naming improvements -- Logic errors -- Best practices for beginners -- Code completion suggestions -- Performance tips - -Be concise and actionable. Only suggest fixes that directly improve the code. -`; - - const result = await this.model.generateContent(prompt); - const responseText = result.response.text(); - - // Extract JSON from response - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const analysis = JSON.parse(jsonMatch[0]); - return { - success: true, - analysis: analysis - }; - } - - return { - success: true, - analysis: { lineAnalysis: [], currentLineSuggestions: [] } - }; - - } catch (error) { - console.error('Real-time analysis error:', error); - return { - success: false, - error: error.message, - analysis: { lineAnalysis: [] } - }; - } - } - - /** - * Provide contextual help based on cursor position - * @param {string} line - Current line text - * @param {Object} token - Current token at cursor - * @param {Object} cursor - Cursor position - * @param {string} language - Programming language - * @param {Object} problem - Problem context - * @returns {Object} Contextual suggestions - */ - async getContextualHelp(line, token, cursor, language, problem) { - if (!this.isEnabled) { - return { suggestions: [] }; - } - - try { - const prompt = ` -You are providing contextual help for a ${language} programmer at cursor position. - -CONTEXT: -- Current line: "${line}" -- Token at cursor: ${JSON.stringify(token)} -- Cursor position: row ${cursor.row}, column ${cursor.column} -- Problem: ${problem?.title || 'Coding Challenge'} - -Provide helpful suggestions for what the user might want to do next. - -Return JSON: -{ - "suggestions": [ - { - "message": "", - "code": "", - "explanation": "" - } - ] -} - -Focus on: -- Code completion -- Method suggestions -- Variable suggestions -- Common patterns for this context -- Problem-specific hints (without giving away the solution) -`; - - const result = await this.model.generateContent(prompt); - const responseText = result.response.text(); - - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const suggestions = JSON.parse(jsonMatch[0]); - return { - success: true, - suggestions: suggestions.suggestions || [] - }; - } - - return { success: true, suggestions: [] }; - - } catch (error) { - console.error('Contextual help error:', error); - return { success: false, error: error.message, suggestions: [] }; - } - } - - /** - * Analyze test case failures and provide specific fixes - * @param {string} code - User's code - * @param {string} language - Programming language - * @param {Object} problem - Problem context - * @param {Object} testResults - Test results with failures - * @returns {Object} Analysis of test failures with suggested fixes - */ - async analyzeTestCaseFailure(code, language, problem, testResults) { - if (!this.isEnabled) { - return { failedTests: [] }; - } - - try { - const prompt = ` -You are an AI debugging assistant helping a beginner fix failing test cases. - -PROBLEM: ${problem?.title || 'Coding Challenge'} -DESCRIPTION: ${problem?.description || 'No description'} - -USER'S CODE: -\`\`\`${language} -${code} -\`\`\` - -FAILED TEST RESULTS: -${JSON.stringify(testResults, null, 2)} - -Analyze why the tests are failing and provide specific, actionable fixes. - -Return JSON: -{ - "analysis": "Brief explanation of what's going wrong", - "failedTests": [ - { - "name": "", - "expected": "", - "actual": "", - "issue": "", - "suggestion": "", - "codeChange": "", - "line": - } - ], - "commonIssues": [ - "" - ], - "nextSteps": [ - "" - ] -} - -Be specific about: -- Exact code changes needed -- Line numbers where changes should be made -- Logic errors vs syntax errors -- Edge cases not handled -- Off-by-one errors -- Input/output format issues -`; - - const result = await this.model.generateContent(prompt); - const responseText = result.response.text(); - - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const analysis = JSON.parse(jsonMatch[0]); - return { - success: true, - analysis: analysis - }; - } - - return { - success: true, - analysis: { failedTests: [], commonIssues: [], nextSteps: [] } - }; - - } catch (error) { - console.error('Test failure analysis error:', error); - return { - success: false, - error: error.message, - analysis: { failedTests: [] } - }; - } - } - - /** - * Format AI response to ensure consistency - */ - formatResponse(response) { - return { - suggestions: response.suggestions || [], - errors: response.errors || [], - warnings: response.warnings || [], - lineSpecific: response.lineSpecific || [] - }; - } - - /** - * Extract suggestions from plain text response (fallback) - */ - extractSuggestionsFromText(text, currentLine) { - const suggestions = []; - const errors = []; - const warnings = []; - - // Simple pattern matching for common suggestions - if (text.toLowerCase().includes('syntax error')) { - errors.push({ - line: currentLine || 0, - type: 'error', - message: 'Syntax error detected', - fix: 'Check for missing semicolons, brackets, or quotes' - }); - } - - if (text.toLowerCase().includes('variable') && text.toLowerCase().includes('not defined')) { - errors.push({ - line: currentLine || 0, - type: 'error', - message: 'Variable not defined', - fix: 'Make sure to declare the variable before using it' - }); - } - - return { suggestions, errors, warnings, lineSpecific: [] }; - } - - /** - * Get language-specific tips for beginners - */ - getLanguageTips(language) { - const tips = { - javascript: [ - "Use 'let' or 'const' instead of 'var' for better scope control", - "Remember to end statements with semicolons", - "Use === instead of == for strict equality", - "Don't forget to handle async operations with await or .then()" - ], - python: [ - "Use proper indentation (4 spaces) for code blocks", - "Remember that Python is case-sensitive", - "Use descriptive variable names", - "Don't forget colons after if, for, while, and function definitions" - ], - 'c++': [ - "Include necessary headers like #include ", - "Don't forget to return 0 in main function", - "Use proper variable types (int, string, etc.)", - "Remember semicolons after statements" - ], - java: [ - "Every Java program needs a main method", - "Class names should start with uppercase", - "Don't forget semicolons after statements", - "Use proper access modifiers (public, private)" - ] - }; - - return tips[language] || []; - } -} - -export { AIAssistanceService }; diff --git a/server/arenaDatabase.js b/server/arenaDatabase.js index aa4e1f6..ad6d7c7 100644 --- a/server/arenaDatabase.js +++ b/server/arenaDatabase.js @@ -67,34 +67,14 @@ const arenaQueueSchema = new mongoose.Schema({ status: { type: String, enum: ['waiting', 'matched'], default: 'waiting' } }); -// Arena Player Stats Schema -const arenaPlayerStatsSchema = new mongoose.Schema({ - userId: { type: String, required: true, unique: true }, - username: { type: String, required: true }, - totalMatches: { type: Number, default: 0 }, - wins: { type: Number, default: 0 }, - losses: { type: Number, default: 0 }, - draws: { type: Number, default: 0 }, - totalScore: { type: Number, default: 0 }, - totalBonusPoints: { type: Number, default: 0 }, - averageScore: { type: Number, default: 0 }, - winRate: { type: Number, default: 0 }, - questionsCompleted: { type: Number, default: 0 }, - fastestSolve: { type: Number }, // in seconds - easyWins: { type: Number, default: 0 }, - mediumWins: { type: Number, default: 0 }, - hardWins: { type: Number, default: 0 }, - currentStreak: { type: Number, default: 0 }, - bestStreak: { type: Number, default: 0 }, - lastMatchAt: { type: Date }, - createdAt: { type: Date, default: Date.now }, - updatedAt: { type: Date, default: Date.now } -}); - class ArenaDBHandler { static ArenaMatch = mongoose.model("ArenaMatch", arenaMatchSchema); static ArenaQueue = mongoose.model("ArenaQueue", arenaQueueSchema); - static ArenaPlayerStats = mongoose.model("ArenaPlayerStats", arenaPlayerStatsSchema); + // ArenaPlayerStats is now managed by UserStatsService + + constructor(userStatsService) { + this.userStatsService = userStatsService; + } // Matchmaking: Join queue async joinQueue(userId, username, difficulty) { @@ -497,47 +477,44 @@ class ArenaDBHandler { } } - // Update player statistics + // Update player statistics using unified UserStatsService async updatePlayerStats(match) { try { - const player1Stats = await this.getOrCreatePlayerStats(match.player1.userId, match.player1.username); - const player2Stats = await this.getOrCreatePlayerStats(match.player2.userId, match.player2.username); - - // Update both players' stats - for (const [player, stats] of [[match.player1, player1Stats], [match.player2, player2Stats]]) { - stats.totalMatches += 1; - stats.totalScore += player.score; - stats.totalBonusPoints += player.bonusPoints; - stats.questionsCompleted += player.questionsCompleted; - stats.lastMatchAt = match.endedAt; - - if (match.winner === player.userId) { - stats.wins += 1; - stats.currentStreak += 1; - stats.bestStreak = Math.max(stats.bestStreak, stats.currentStreak); - - // Increment difficulty-specific wins - switch (player.selectedDifficulty) { - case 'easy': stats.easyWins += 1; break; - case 'medium': stats.mediumWins += 1; break; - case 'hard': stats.hardWins += 1; break; - } - } else if (match.winner) { - stats.losses += 1; - stats.currentStreak = 0; - } else { - stats.draws += 1; - } - - // Calculate averages - stats.averageScore = stats.totalScore / stats.totalMatches; - stats.winRate = (stats.wins / stats.totalMatches) * 100; - stats.updatedAt = new Date(); + console.log(`📊 [ArenaDBHandler] Updating arena stats for match ${match.matchId}:`); + console.log(` Winner: ${match.winner || 'Draw'}`); + console.log(` Player1: ${match.player1.username} (Score: ${match.player1.score || 0})`); + console.log(` Player2: ${match.player2.username} (Score: ${match.player2.score || 0})`); + + // Prepare match data for UserStatsService + const matchData = { + player1: { + userId: match.player1.userId, + username: match.player1.username, + score: match.player1.score, + bonusPoints: match.player1.bonusPoints, + questionsCompleted: match.player1.questionsCompleted, + selectedDifficulty: match.player1.selectedDifficulty + }, + player2: { + userId: match.player2.userId, + username: match.player2.username, + score: match.player2.score, + bonusPoints: match.player2.bonusPoints, + questionsCompleted: match.player2.questionsCompleted, + selectedDifficulty: match.player2.selectedDifficulty + }, + winner: match.winner, + endedAt: match.endedAt + }; - await stats.save(); - } + // Update arena stats through unified service + const updates = await this.userStatsService.updateArenaStats(matchData); + + console.log(`✅ [ArenaDBHandler] Arena stats updated for ${updates.length} players`); + return updates; } catch (error) { - console.error('[ArenaDBHandler] updatePlayerStats error:', error); + console.error(`❌ [ArenaDBHandler] updatePlayerStats error for match ${match.matchId}:`, error); + throw error; } } @@ -595,51 +572,55 @@ class ArenaDBHandler { return Math.max(0, question.timeLimit - elapsed); } - // Get player stats + // Get player stats using unified service async getPlayerStats(userId) { try { - const stats = await ArenaDBHandler.ArenaPlayerStats.findOne({ userId }); - return stats || { - totalMatches: 0, - wins: 0, - losses: 0, - draws: 0, - winRate: 0, - averageScore: 0, - currentStreak: 0, - bestStreak: 0 - }; + return await this.userStatsService.getArenaStats(userId); } catch (error) { console.error('[ArenaDBHandler] getPlayerStats error:', error); throw error; } } - // Get leaderboard + // Get leaderboard using unified service async getArenaLeaderboard(limit = 20) { try { - const leaderboard = await ArenaDBHandler.ArenaPlayerStats - .find({ totalMatches: { $gt: 0 } }) - .sort({ winRate: -1, totalScore: -1 }) - .limit(limit); - - return leaderboard.map((player, index) => ({ - rank: index + 1, - username: player.username, - totalMatches: player.totalMatches, - wins: player.wins, - losses: player.losses, - draws: player.draws, - winRate: Math.round(player.winRate * 100) / 100, - averageScore: Math.round(player.averageScore * 100) / 100, - currentStreak: player.currentStreak, - bestStreak: player.bestStreak - })); + return await this.userStatsService.getArenaLeaderboard(limit); } catch (error) { console.error('[ArenaDBHandler] getArenaLeaderboard error:', error); return []; } } + + // Abandon a match due to timeout or player disconnect + async abandonMatch(matchId, reason = 'timeout') { + try { + const match = await ArenaDBHandler.ArenaMatch.findOne({ matchId }); + if (!match) { + throw new Error(`Match ${matchId} not found`); + } + + // Don't abandon already completed matches + if (match.status === 'completed') { + console.log(`Match ${matchId} already completed, skipping abandon`); + return match; + } + + // Mark as abandoned + match.status = 'abandoned'; + match.endedAt = new Date(); + match.totalDuration = (match.endedAt - match.startedAt) / 1000; + match.endReason = reason; + + await match.save(); + + console.log(`Match ${matchId} abandoned due to: ${reason}`); + return match; + } catch (error) { + console.error('[ArenaDBHandler] abandonMatch error:', error); + throw error; + } + } } export { ArenaDBHandler }; diff --git a/server/arenaSocket.js b/server/arenaSocket.js index 235978d..aa9a227 100644 --- a/server/arenaSocket.js +++ b/server/arenaSocket.js @@ -184,7 +184,46 @@ class ArenaSocketHandler { await this.arenaDB.leaveQueue(socket.userId); // Handle match abandonment if in active match - // (You might want to pause the match or mark as abandoned) + for (const [matchId, matchData] of this.activeMatches.entries()) { + if (matchData.player1 === socket.userId || matchData.player2 === socket.userId) { + console.log(`🚫 [Arena] Player ${socket.userId} disconnected from active match ${matchId}`); + + // Clear timeout for this match since it's being abandoned + if (matchData.timeout) { + clearTimeout(matchData.timeout); + } + + // Mark match as abandoned in database + try { + await this.arenaDB.abandonMatch(matchId, socket.userId); + console.log(`🏃 [Arena] Match ${matchId} abandoned due to player disconnect`); + } catch (error) { + console.error(`❌ [Arena] Error abandoning match ${matchId}:`, error); + } + + // Clean up match data + this.activeMatches.delete(matchId); + this.playerProgress.delete(matchData.player1); + this.playerProgress.delete(matchData.player2); + + // Notify the other player if still connected + const otherPlayerId = matchData.player1 === socket.userId ? matchData.player2 : matchData.player1; + const otherSocket = this.findSocketByUserId(otherPlayerId); + + if (otherSocket) { + otherSocket.emit('arena:opponent-disconnected', { + message: 'Your opponent disconnected. You win by forfeit!', + matchId: matchId, + winner: otherPlayerId + }); + otherSocket.leave(`match:${matchId}`); + } + + // Broadcast updated stats + await this.broadcastArenaStats(); + break; + } + } } }); }); @@ -229,13 +268,23 @@ class ArenaSocketHandler { player1Socket.emit('arena:match-found', matchData1); player2Socket.emit('arena:match-found', matchData2); - // Store active match + // Store active match with timeout this.activeMatches.set(match.matchId, { player1: match.player1.userId, player2: match.player2.userId, - currentQuestionIndex: 0 + currentQuestionIndex: 0, + startTime: new Date() }); + // Set 30-minute match timeout + const matchTimeout = setTimeout(async () => { + console.log(`⏰ [Arena] Match ${match.matchId} timed out after 30 minutes`); + await this.endMatchOnTimeout(match.matchId); + }, 30 * 60 * 1000); // 30 minutes in milliseconds + + // Store timeout reference for cleanup + this.activeMatches.get(match.matchId).timeout = matchTimeout; + console.log('🎮 [Arena] Match setup complete!'); // Broadcast updated arena stats to all clients @@ -854,6 +903,12 @@ class ArenaSocketHandler { this.playerProgress.delete(match.player1.userId); this.playerProgress.delete(match.player2.userId); + // Clear match timeout if it exists + const activeMatch = this.activeMatches.get(matchId); + if (activeMatch && activeMatch.timeout) { + clearTimeout(activeMatch.timeout); + } + // Remove from active matches this.activeMatches.delete(matchId); @@ -870,6 +925,99 @@ class ArenaSocketHandler { } } + // Handle match timeout (30 minutes) + async endMatchOnTimeout(matchId) { + try { + console.log(`⏰ [Arena] Ending match ${matchId} due to timeout`); + + // Get match data + const match = await this.arenaDB.getMatch(matchId); + if (!match) { + console.error(`❌ [Arena] Match ${matchId} not found for timeout`); + return; + } + + // If match is already completed, nothing to do + if (match.status === 'completed') { + console.log(`✅ [Arena] Match ${matchId} already completed, ignoring timeout`); + return; + } + + // Get current scores for both players + const player1Progress = this.playerProgress.get(match.player1.userId) || { + currentQuestionIndex: 0, + correctAnswers: 0, + totalScore: 0 + }; + const player2Progress = this.playerProgress.get(match.player2.userId) || { + currentQuestionIndex: 0, + correctAnswers: 0, + totalScore: 0 + }; + + // Determine winner based on current scores + let winner = null; + if (player1Progress.totalScore > player2Progress.totalScore) { + winner = match.player1.userId; + } else if (player2Progress.totalScore > player1Progress.totalScore) { + winner = match.player2.userId; + } + // If scores are equal, it's a draw (winner remains null) + + // End the match with timeout reason + await this.arenaDB.endMatch(matchId, { + winner, + player1FinalScore: player1Progress.totalScore, + player2FinalScore: player2Progress.totalScore, + player1QuestionsCompleted: player1Progress.correctAnswers, + player2QuestionsCompleted: player2Progress.correctAnswers, + endReason: 'timeout', + totalDuration: 30 * 60 * 1000 // 30 minutes in milliseconds + }); + + // Notify both players about the timeout + const player1Socket = this.findSocketByUserId(match.player1.userId); + const player2Socket = this.findSocketByUserId(match.player2.userId); + + const timeoutMessage = { + type: 'timeout', + winner, + reason: 'Match ended due to 30-minute time limit', + finalScores: { + [match.player1.userId]: player1Progress.totalScore, + [match.player2.userId]: player2Progress.totalScore + }, + questionsCompleted: { + [match.player1.userId]: player1Progress.correctAnswers, + [match.player2.userId]: player2Progress.correctAnswers + } + }; + + if (player1Socket) { + player1Socket.emit('arena:match-ended', timeoutMessage); + player1Socket.leave(`match:${matchId}`); + } + + if (player2Socket) { + player2Socket.emit('arena:match-ended', timeoutMessage); + player2Socket.leave(`match:${matchId}`); + } + + // Clean up match data + this.playerProgress.delete(match.player1.userId); + this.playerProgress.delete(match.player2.userId); + this.activeMatches.delete(matchId); + + // Broadcast updated stats + await this.broadcastArenaStats(); + + console.log(`✅ [Arena] Match ${matchId} ended due to timeout. Winner: ${winner || 'Draw'}`); + + } catch (error) { + console.error(`❌ [Arena] Error ending match ${matchId} on timeout:`, error); + } + } + // Find socket by user ID findSocketByUserId(userId) { for (const [socketId, socket] of this.io.sockets.sockets) { diff --git a/server/database.js b/server/database.js index 1ceb62a..ff6f120 100644 --- a/server/database.js +++ b/server/database.js @@ -29,9 +29,24 @@ class UserDBHandler { problemsSolved: { type: Number, default: 0 }, easyCount: { type: Number, default: 0 }, mediumCount: { type: Number, default: 0 }, - hardCount: { type: Number, default: 0 } + hardCount: { type: Number, default: 0 }, + realWorldCount: { type: Number, default: 0 } }); + // Add indexes for leaderboard performance + static { + // Compound index for leaderboard sorting (rank DESC, problemsSolved DESC, streak_count DESC) + UserDBHandler.userSchema.index({ rank: -1, problemsSolved: -1, streak_count: -1 }); + + // Index for userID lookups + UserDBHandler.userSchema.index({ userID: 1 }); + + // Individual indexes for rank position calculations + UserDBHandler.userSchema.index({ rank: -1 }); + UserDBHandler.userSchema.index({ problemsSolved: -1 }); + UserDBHandler.userSchema.index({ streak_count: -1 }); + } + static Users = mongoose.model("Users", UserDBHandler.userSchema); async middleware_userAuth(req, res, next) { @@ -89,36 +104,7 @@ class UserDBHandler { } /** - * Get leaderboard data - * @param {Request} req The request object - * @param {Response} res The response object - */ - async endpoint_getLeaderboard(req, res) { - try { - const limit = parseInt(req.query.limit) || 50; - const leaderboard = await this.getLeaderboard(limit); - - // Add ranking positions - const rankedLeaderboard = leaderboard.map((user, index) => ({ - ...user.toObject(), - position: index + 1 - })); - - res.json({ - success: true, - leaderboard: rankedLeaderboard, - totalUsers: rankedLeaderboard.length - }); - } catch (error) { - console.error('Leaderboard endpoint error:', error); - res.status(500).json({ - success: false, - error: 'Failed to fetch leaderboard' - }); - } - } - - /** + * Migrate existing users to include new stats fields * Requires the req object to contain the param[optional] set to make sure the rank is set and not incremented * @param {Request} req The request object * @param {Response} res The response object @@ -147,35 +133,6 @@ class UserDBHandler { } } - /** - * Requires the req object to contain the param[optional] set to make sure the rank is set and not incremented - * @param {Request} req The request object - * @param {Response} res The response object - */ - async endpoint_incrementStreakCount(req, res) { - if (req.params.value) { - try { - this.incrementStreakCount(req.auth.userId, req.params.value, req.params.set === "true"); - res.json({ message: 'ok' }); - } catch (err) { - res.json({ error: err.toString() }); - return; - } - } - else { - res.json({ error: "Request parameter value missing" }); - } - } - - async incrementStreakCount(_userId, value, set = false) { - if (set) { - await UserDBHandler.Users.updateOne({ userID: _userId }, { $set: { streak_count: value } }); - } - else { - await UserDBHandler.Users.updateOne({ userID: _userId }, { $inc: { streak_count: value } }); - } - } - /** * Requires the req object to contain the param[optional] set to make sure the rank is set and not incremented * @param {Request} req The request object @@ -220,209 +177,6 @@ class UserDBHandler { } } - /** - * Get leaderboard data - * @param {number} limit Number of users to return (default: 50) - * @returns {Array} Array of user objects sorted by rank - */ - async getLeaderboard(limit = 50) { - try { - return await UserDBHandler.Users - .find({}, { - name: 1, - userID: 1, - rank: 1, - contests_count: 1, - streak_count: 1, - problemsSolved: 1, - easyCount: 1, - mediumCount: 1, - hardCount: 1, - lastSolvedDate: 1, - _id: 0 - }) - .sort({ - problemsSolved: -1, - hardCount: -1, - mediumCount: -1, - easyCount: -1, - lastSolvedDate: -1 - }) - .limit(limit); - } catch (error) { - console.error('[UserDBHandler Error] getLeaderboard failed:', error); - throw error; - } - } - - /** - * Get user statistics - * @param {string} userId User ID - * @returns {Object} User stats - */ - async getUserStats(userId) { - try { - return await UserDBHandler.Users.findOne( - { userID: userId }, - { - rank: 1, - contests_count: 1, - streak_count: 1, - name: 1, - problemsSolved: 1, - easyCount: 1, - mediumCount: 1, - hardCount: 1, - lastSolvedDate: 1, - _id: 0 - } - ); - } catch (error) { - console.error('[UserDBHandler Error] getUserStats failed:', error); - throw error; - } - } - - /** - * Update user statistics when a problem is successfully solved - * @param {string} userId User ID - * @param {string} difficulty Problem difficulty (easy, medium, hard) - * @param {string} category Problem category - * @returns {Object} Updated user stats - */ - async updateUserOnProblemSolved(userId, difficulty, category) { - try { - // Calculate rank points based on difficulty - const rankPoints = { - 'easy': 10, - 'medium': 25, - 'hard': 50 - }; - - const points = rankPoints[difficulty] || 10; - - // Prepare update object - const updateObj = { - $inc: { - rank: points, - problemsSolved: 1 - }, - $set: { - lastSolvedDate: new Date() - } - }; - - // Increment difficulty-specific counter - if (difficulty === 'easy') { - updateObj.$inc.easyCount = 1; - } else if (difficulty === 'medium') { - updateObj.$inc.mediumCount = 1; - } else if (difficulty === 'hard') { - updateObj.$inc.hardCount = 1; - } - - // Update user data - await UserDBHandler.Users.updateOne( - { userID: userId }, - updateObj - ); - - // Handle streak separately - await this.checkAndUpdateStreak(userId); - - // Return updated user stats - return await this.getUserStats(userId); - } catch (error) { - console.error('[UserDBHandler Error] updateUserOnProblemSolved failed:', error); - throw error; - } - } - - /** - * API endpoint to update user stats on problem solve - * @param {Request} req - * @param {Response} res - */ - async endpoint_updateOnProblemSolved(req, res) { - try { - const { difficulty, category } = req.body; - const userId = req.auth?.userId; - - if (!userId) { - return res.status(401).json({ success: false, error: 'Authentication required' }); - } - - if (!difficulty) { - return res.status(400).json({ success: false, error: 'Difficulty is required' }); - } - - const updatedStats = await this.updateUserOnProblemSolved(userId, difficulty, category); - - res.json({ - success: true, - message: 'Stats updated successfully', - stats: updatedStats - }); - } catch (error) { - console.error('Update stats error:', error); - res.status(500).json({ success: false, error: 'Failed to update stats' }); - } - } - - /** - * Check and update daily streak - * @param {string} userId User ID - */ - async checkAndUpdateStreak(userId) { - try { - const user = await UserDBHandler.Users.findOne({ userID: userId }); - if (!user) return; - - const today = new Date(); - const lastSolved = user.lastSolvedDate ? new Date(user.lastSolvedDate) : null; - - if (lastSolved) { - const daysDiff = Math.floor((today - lastSolved) / (1000 * 60 * 60 * 24)); - - if (daysDiff === 1) { - // Continue streak - await UserDBHandler.Users.updateOne( - { userID: userId }, - { - $inc: { streak_count: 1 }, - $set: { lastSolvedDate: today } - } - ); - } else if (daysDiff > 1) { - // Reset streak - await UserDBHandler.Users.updateOne( - { userID: userId }, - { - $set: { - streak_count: 1, - lastSolvedDate: today - } - } - ); - } - // If daysDiff === 0, user already solved today, don't update streak - } else { - // First time solving - await UserDBHandler.Users.updateOne( - { userID: userId }, - { - $set: { - streak_count: 1, - lastSolvedDate: today - } - } - ); - } - } catch (error) { - console.error('[UserDBHandler Error] checkAndUpdateStreak failed:', error); - } - } - /** * Migrate existing users to include new stats fields */ diff --git a/server/index.js b/server/index.js index 5246565..ca86248 100644 --- a/server/index.js +++ b/server/index.js @@ -8,11 +8,12 @@ import path from "path"; import { createServer } from "http"; import { MongooseConnect, UserDBHandler } from "./database.js"; +import { UserStatsService } from "./userStatsService.js"; import { CodeRunner } from "./codeRun.js"; import { ProblemDBHandler } from "./problemDatabase.js"; import { ArenaDBHandler } from "./arenaDatabase.js"; import { ArenaSocketHandler } from "./arenaSocket.js"; -import { AIAssistanceService } from "./aiAssistance.js"; + import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -29,6 +30,9 @@ const server = createServer(app); // Initialize Arena Socket Handler const arenaSocketHandler = new ArenaSocketHandler(server); +// Initialize User Stats Service with arena socket handler for real-time stats +const userStatsService = new UserStatsService(arenaSocketHandler); + // CORS middleware app.use(cors({ origin: process.env.FRONTEND_URL || '*', @@ -51,13 +55,10 @@ app.use(session({ const codeRunnerHandler = new CodeRunner(); // problem database handler ------- -const problemDBHandler = new ProblemDBHandler(); +const problemDBHandler = new ProblemDBHandler(userStatsService); // arena database handler ---------- -const arenaDBHandler = new ArenaDBHandler(); - -// AI assistance handler ----------- -const aiAssistanceService = new AIAssistanceService(); +const arenaDBHandler = new ArenaDBHandler(userStatsService); // user database handler ------------- MongooseConnect.connect(process.env.MONGO_DB_URL); @@ -66,15 +67,27 @@ const uDBHandler = new UserDBHandler(); // Run migration for existing users uDBHandler.migrateUsersStats().catch(console.error); +// Run migration for real-world stats +userStatsService.migrateUsersForRealWorld().catch(console.error); + +// Start periodic 30-minute match timeout enforcement (every 5 minutes) +setInterval(async () => { + try { + const cleanedCount = await userStatsService.cleanupStaleMatches(); + if (cleanedCount > 0) { + console.log(`🔧 [Server] Periodic cleanup: ended ${cleanedCount} matches that exceeded 30-minute limit`); + } + } catch (error) { + console.error('❌ [Server] Periodic match cleanup error:', error); + } +}, 5 * 60 * 1000); // Every 5 minutes + // authentication ----------------- app.use(clerk.clerkMiddleware()); // base entry point of server app.use('/public', express.static('client/public')); -// Serve test file (remove in production) -app.use('/test-stats.html', express.static('test-stats.html')); - app.use('/private', clerk.requireAuth({ signInUrl: process.env.CLERK_SIGN_IN_URL, signUpUrl: process.env.CLERK_SIGN_UP_URL }), uDBHandler.middleware_userAuth.bind(uDBHandler), express.static('client/private')); @@ -86,7 +99,7 @@ app.use('/api', express.json()); // Add JSON parsing middleware // Re-enable essential endpoints app.get('/api/incrank/:value/:set', clerk.requireAuth(), uDBHandler.endpoint_incrementUserRank.bind(uDBHandler)); -app.get('/api/incstreak/:value/:set', clerk.requireAuth(), uDBHandler.endpoint_incrementStreakCount.bind(uDBHandler)); +app.get('/api/incstreak/:value/:set', clerk.requireAuth(), userStatsService.endpoint_incrementStreakCount.bind(userStatsService)); app.get('/api/inccontest/:value/:set', clerk.requireAuth(), uDBHandler.endpoint_incrementContestsCount.bind(uDBHandler)); // Health check endpoint @@ -115,31 +128,21 @@ app.get('/api/user/solved-problems', // User stats endpoints app.get('/api/userdata', clerk.requireAuth(), uDBHandler.endpoint_userData.bind(uDBHandler)); -app.get('/api/leaderboard', uDBHandler.endpoint_getLeaderboard.bind(uDBHandler)); + +// New unified stats and leaderboard endpoints +app.get('/api/user/stats', clerk.requireAuth(), userStatsService.endpoint_getUserStats.bind(userStatsService)); +app.get('/api/user/rank-position', clerk.requireAuth(), userStatsService.endpoint_getUserRankPosition.bind(userStatsService)); +app.get('/api/leaderboard', userStatsService.endpoint_getLeaderboard.bind(userStatsService)); app.post('/api/user/problem-solved', express.json(), clerk.requireAuth(), - uDBHandler.endpoint_updateOnProblemSolved.bind(uDBHandler) + userStatsService.endpoint_updateOnProblemSolved.bind(userStatsService) ); -// Arena endpoints -app.get('/api/arena/stats', async (req, res) => { - try { - const stats = await arenaSocketHandler.getArenaStats(); - res.json({ success: true, stats }); - } catch (error) { - res.status(500).json({ success: false, error: 'Failed to get arena stats' }); - } -}); - -app.get('/api/arena/leaderboard', async (req, res) => { - try { - const leaderboard = await arenaDBHandler.getArenaLeaderboard(); - res.json({ success: true, leaderboard }); - } catch (error) { - res.status(500).json({ success: false, error: 'Failed to get arena leaderboard' }); - } -}); +// Arena endpoints - now unified through UserStatsService +app.get('/api/arena/stats', clerk.requireAuth(), userStatsService.endpoint_getArenaStats.bind(userStatsService)); +app.get('/api/arena/leaderboard', userStatsService.endpoint_getArenaLeaderboard.bind(userStatsService)); +app.get('/api/arena/system-stats', userStatsService.endpoint_getArenaSystemStats.bind(userStatsService)); app.get('/api/arena/player-stats/:userId', async (req, res) => { try { @@ -153,156 +156,6 @@ app.get('/api/arena/player-stats/:userId', async (req, res) => { app.post('/api/run/:lang', express.json(), codeRunnerHandler.endpoint.bind(codeRunnerHandler)); -// AI Assistance endpoints -app.post('/api/ai/analyze-code', express.json({ limit: '10mb' }), async (req, res) => { - try { - const { code, language, problem, currentLine } = req.body; - - if (!code || !language) { - return res.status(400).json({ - success: false, - error: 'Code and language are required' - }); - } - - const analysis = await aiAssistanceService.analyzeCodeLine(code, language, problem, currentLine); - res.json({ success: true, analysis }); - } catch (error) { - console.error('AI Analysis Error:', error); - res.status(500).json({ - success: false, - error: 'AI analysis failed' - }); - } -}); - -app.post('/api/ai/code-completion', express.json(), async (req, res) => { - try { - const { code, language, cursorLine, cursorColumn } = req.body; - - if (!code || !language || cursorLine === undefined || cursorColumn === undefined) { - return res.status(400).json({ - success: false, - error: 'Code, language, and cursor position are required' - }); - } - - const completions = await aiAssistanceService.getCodeCompletion(code, language, cursorLine, cursorColumn); - res.json({ success: true, completions }); - } catch (error) { - console.error('Code Completion Error:', error); - res.status(500).json({ - success: false, - error: 'Code completion failed' - }); - } -}); - -app.post('/api/ai/suggest-fix', express.json(), async (req, res) => { - try { - const { code, language, errorMessage } = req.body; - - if (!code || !language || !errorMessage) { - return res.status(400).json({ - success: false, - error: 'Code, language, and error message are required' - }); - } - - const fixSuggestion = await aiAssistanceService.suggestFix(code, language, errorMessage); - res.json({ success: true, fixSuggestion }); - } catch (error) { - console.error('Fix Suggestion Error:', error); - res.status(500).json({ - success: false, - error: 'Fix suggestion failed' - }); - } -}); - -app.get('/api/ai/language-tips/:language', async (req, res) => { - try { - const { language } = req.params; - const tips = aiAssistanceService.getLanguageTips(language); - res.json({ success: true, tips }); - } catch (error) { - console.error('Language Tips Error:', error); - res.status(500).json({ - success: false, - error: 'Failed to get language tips' - }); - } -}); - -// Enhanced AI Assistance endpoints for real-time per-line help -app.post('/api/ai/real-time-analysis', express.json({ limit: '10mb' }), async (req, res) => { - try { - const { code, currentLine, currentLineText, language, problem } = req.body; - - if (!code || currentLine === undefined || !language) { - return res.status(400).json({ - success: false, - error: 'Code, currentLine, and language are required' - }); - } - - const analysis = await aiAssistanceService.performRealTimeAnalysis( - code, currentLine, currentLineText, language, problem - ); - res.json(analysis); - } catch (error) { - console.error('Real-time Analysis Error:', error); - res.status(500).json({ - success: false, - error: 'Real-time analysis failed' - }); - } -}); - -app.post('/api/ai/contextual-help', express.json(), async (req, res) => { - try { - const { line, token, cursor, language, problem } = req.body; - - if (!line || !language || !cursor) { - return res.status(400).json({ - success: false, - error: 'Line, language, and cursor position are required' - }); - } - - const help = await aiAssistanceService.getContextualHelp(line, token, cursor, language, problem); - res.json(help); - } catch (error) { - console.error('Contextual Help Error:', error); - res.status(500).json({ - success: false, - error: 'Contextual help failed' - }); - } -}); - -app.post('/api/ai/analyze-test-failure', express.json({ limit: '10mb' }), async (req, res) => { - try { - const { code, language, problem, testResults } = req.body; - - if (!code || !language || !testResults) { - return res.status(400).json({ - success: false, - error: 'Code, language, and test results are required' - }); - } - - const analysis = await aiAssistanceService.analyzeTestCaseFailure(code, language, problem, testResults); - res.json(analysis); - } catch (error) { - console.error('Test Failure Analysis Error:', error); - res.status(500).json({ - success: false, - error: 'Test failure analysis failed' - }); - } -}); - // main redirect app.get('/', (req, res) => { res.redirect('/public/LandingPage/'); diff --git a/server/problemDatabase.js b/server/problemDatabase.js index 020e316..581b983 100644 --- a/server/problemDatabase.js +++ b/server/problemDatabase.js @@ -6,7 +6,7 @@ const problemSchema = new mongoose.Schema({ problemId: { type: String, unique: true, required: true }, title: { type: String, required: true }, description: { type: String, required: true }, - difficulty: { type: String, enum: ['easy', 'medium', 'hard'], required: true }, + difficulty: { type: String, enum: ['easy', 'medium', 'hard', 'real-world'], required: true }, category: { type: String, required: true }, tags: [String], constraints: String, @@ -63,6 +63,10 @@ class ProblemDBHandler { static Submissions = mongoose.model("Submissions", submissionSchema); static UserProblemSolved = mongoose.model("UserProblemSolved", userProblemSolvedSchema); + constructor(userStatsService) { + this.userStatsService = userStatsService; + } + // Get all problems with filtering async getProblems(filters = {}) { try { @@ -306,6 +310,8 @@ class ProblemDBHandler { problemId, code, language, + difficulty: problem.difficulty, + category: problem.category, ...validationResult }); @@ -315,6 +321,16 @@ class ProblemDBHandler { if (validationResult.status === 'accepted') { if (!alreadySolved) { + // Ensure difficulty and category are not undefined + if (!problem.difficulty || !problem.category) { + console.error('[SUBMISSION ERROR] Problem missing difficulty or category:', { + problemId: problem.problemId, + difficulty: problem.difficulty, + category: problem.category + }); + throw new Error(`Problem ${problemId} has missing difficulty or category`); + } + // Mark as solved and update stats only for first-time solve isFirstSolve = await this.markProblemAsSolved( userId, @@ -326,9 +342,8 @@ class ProblemDBHandler { if (isFirstSolve) { try { - // Update user stats for first-time solve - const userDBHandler = new UserDBHandler(); - updatedStats = await userDBHandler.updateUserOnProblemSolved( + // Update user stats for first-time solve using new stats service + updatedStats = await this.userStatsService.updateUserOnProblemSolved( userId, problem.difficulty, problem.category @@ -539,23 +554,6 @@ class ProblemDBHandler { } ); - // If solution is accepted, track in userProblemSolved - if (submissionData.status === 'accepted') { - await ProblemDBHandler.UserProblemSolved.updateOne( - { userId: submissionData.userId, problemId: submissionData.problemId }, - { - $setOnInsert: { - difficulty: submissionData.difficulty, - category: submissionData.category, - firstSolvedAt: new Date(), - bestSubmissionId: submissionId - }, - $inc: { totalAttempts: 1 } - }, - { upsert: true } - ); - } - return submission; } catch (error) { console.error('Create submission error:', error); diff --git a/server/seedDatabase.js b/server/seedDatabase.js index e2fd642..dda7c15 100644 --- a/server/seedDatabase.js +++ b/server/seedDatabase.js @@ -1,6 +1,7 @@ -const mongoose = require("mongoose"); -const { ProblemDBHandler } = require("./problemDatabase.js"); -require('dotenv').config(); +import mongoose from "mongoose"; +import { ProblemDBHandler } from "./problemDatabase.js"; +import dotenv from 'dotenv'; +dotenv.config(); // Initial problem data - extracted from all HTML files const initialProblems = [ @@ -411,9 +412,9 @@ const initialProblems = [ problemId: "tic-tac-toe", title: "Tic Tac Toe Game", description: "Implement a two-player Tic Tac Toe game with a console or GUI interface.", - difficulty: "easy", + difficulty: "real-world", category: "games", - tags: ["games", "logic", "arrays"], + tags: ["games", "logic", "arrays", "real-world"], constraints: "3x3 grid, two players, check win conditions", examples: [ { @@ -431,9 +432,9 @@ const initialProblems = [ problemId: "hangman-game", title: "Hangman Game", description: "Create a word-guessing game where players try to guess a hidden word letter by letter.", - difficulty: "easy", + difficulty: "real-world", category: "games", - tags: ["games", "strings", "logic"], + tags: ["games", "strings", "logic", "real-world"], constraints: "Word length: 3-10 characters, maximum 6 wrong guesses", examples: [ { @@ -451,9 +452,9 @@ const initialProblems = [ problemId: "number-guessing", title: "Number Guessing Game", description: "Develop a game where the player guesses a randomly generated number within a limited range.", - difficulty: "easy", + difficulty: "real-world", category: "games", - tags: ["games", "random", "loops"], + tags: ["games", "random", "loops", "real-world"], constraints: "Number range: 1-100, maximum 7 attempts", examples: [ { @@ -473,9 +474,9 @@ const initialProblems = [ problemId: "todo-list", title: "To-Do List Application", description: "Develop a to-do list app with features to add, delete, and mark tasks as complete.", - difficulty: "easy", + difficulty: "real-world", category: "web", - tags: ["web", "crud", "javascript"], + tags: ["web", "crud", "javascript", "real-world"], constraints: "Support CRUD operations, data persistence", examples: [ { @@ -493,9 +494,9 @@ const initialProblems = [ problemId: "weather-app", title: "Weather App", description: "Create an application that fetches and displays weather data from a public API.", - difficulty: "medium", + difficulty: "real-world", category: "web", - tags: ["web", "api", "json"], + tags: ["web", "api", "json", "real-world"], constraints: "Use a weather API, handle API errors, display temperature and conditions", examples: [ { @@ -513,9 +514,9 @@ const initialProblems = [ problemId: "expense-tracker", title: "Expense Tracker", description: "Build an app where users can log expenses, view charts, and manage their budget.", - difficulty: "medium", + difficulty: "real-world", category: "web", - tags: ["web", "finance", "data-visualization"], + tags: ["web", "finance", "data-visualization", "real-world"], constraints: "Categories for expenses, date tracking, budget limits", examples: [ { @@ -535,9 +536,9 @@ const initialProblems = [ problemId: "sentiment-analysis", title: "Sentiment Analysis Tool", description: "Analyze text (e.g., tweets) to detect positive/negative mood using natural language processing.", - difficulty: "hard", + difficulty: "real-world", category: "ai", - tags: ["ai", "nlp", "machine-learning"], + tags: ["ai", "nlp", "machine-learning", "real-world"], constraints: "Classify text as positive, negative, or neutral", examples: [ { @@ -556,9 +557,9 @@ const initialProblems = [ problemId: "spam-classifier", title: "Spam Message Classifier", description: "Train a model to classify messages as spam or not using machine learning techniques.", - difficulty: "medium", + difficulty: "real-world", category: "ai", - tags: ["ai", "classification", "text-processing"], + tags: ["ai", "classification", "text-processing", "real-world"], constraints: "Binary classification: spam or not spam", examples: [ { @@ -653,8 +654,8 @@ async function seedDatabase() { } // Run the seeding function -if (require.main === module) { +if (import.meta.url === `file://${process.argv[1]}`) { seedDatabase(); } -module.exports = { seedDatabase, initialProblems }; +export { seedDatabase, initialProblems }; diff --git a/server/userStatsService.js b/server/userStatsService.js new file mode 100644 index 0000000..ee4025d --- /dev/null +++ b/server/userStatsService.js @@ -0,0 +1,891 @@ +import { UserDBHandler } from './database.js'; +import mongoose from 'mongoose'; + +// Arena Player Stats Schema - Import the schema here for consistency +const arenaPlayerStatsSchema = new mongoose.Schema({ + userId: { type: String, required: true, unique: true }, + username: { type: String, required: true }, + totalMatches: { type: Number, default: 0 }, + wins: { type: Number, default: 0 }, + losses: { type: Number, default: 0 }, + draws: { type: Number, default: 0 }, + totalScore: { type: Number, default: 0 }, + totalBonusPoints: { type: Number, default: 0 }, + averageScore: { type: Number, default: 0 }, + winRate: { type: Number, default: 0 }, + questionsCompleted: { type: Number, default: 0 }, + fastestSolve: { type: Number }, // in seconds + easyWins: { type: Number, default: 0 }, + mediumWins: { type: Number, default: 0 }, + hardWins: { type: Number, default: 0 }, + currentStreak: { type: Number, default: 0 }, + bestStreak: { type: Number, default: 0 }, + lastMatchAt: { type: Date }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now } +}); + +/** + * Common User Statistics Service + * Handles user stats logic for both homepage and leaderboard + */ +class UserStatsService { + + constructor(arenaSocketHandler = null) { + // Initialize ArenaPlayerStats model (check if already exists to avoid conflicts) + try { + this.ArenaPlayerStats = mongoose.model("ArenaPlayerStats"); + } catch (error) { + // Model doesn't exist, create it + this.ArenaPlayerStats = mongoose.model("ArenaPlayerStats", arenaPlayerStatsSchema); + } + + // Store reference to arena socket handler for system stats + this.arenaSocketHandler = arenaSocketHandler; + } + + /** + * Get comprehensive user statistics + * @param {string} userId - User ID + * @returns {Object} User stats object + */ + async getUserStats(userId) { + try { + const user = await UserDBHandler.Users.findOne({ userID: userId }); + + if (!user) { + return { + problemsSolved: 0, + easyCount: 0, + mediumCount: 0, + hardCount: 0, + realWorldCount: 0, + rank: 0, + streak_count: 0 + }; + } + + return { + problemsSolved: user.problemsSolved || 0, + easyCount: user.easyCount || 0, + mediumCount: user.mediumCount || 0, + hardCount: user.hardCount || 0, + realWorldCount: user.realWorldCount || 0, + rank: user.rank || 0, + streak_count: user.streak_count || 0, + lastSolvedDate: user.lastSolvedDate + }; + } catch (error) { + console.error('[UserStatsService Error] Failed to get user stats:', error); + throw error; + } + } + + /** + * Get leaderboard with user statistics and correct ranking positions + * @param {number} limit - Number of users to return + * @returns {Array} Array of user objects with stats and rank positions + */ + async getLeaderboard(limit = 50) { + try { + const users = await UserDBHandler.Users.find({}) + .sort({ + rank: -1, + problemsSolved: -1, + streak_count: -1, + userID: 1 // Add userID as a tiebreaker for consistent ordering + }) + .select('userID name problemsSolved easyCount mediumCount hardCount realWorldCount rank streak_count lastSolvedDate'); + + // Calculate rank positions with proper tie handling + const leaderboardWithRanks = []; + let currentRank = 1; + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + + // If this is not the first user, check if stats are different from previous user + if (i > 0) { + const prevUser = users[i - 1]; + if (user.rank !== prevUser.rank || + user.problemsSolved !== prevUser.problemsSolved || + user.streak_count !== prevUser.streak_count) { + currentRank = i + 1; // Update rank position only when stats differ + } + // If stats are the same, keep the same rank as previous user + } + + leaderboardWithRanks.push({ + userID: user.userID, + name: user.name, + problemsSolved: user.problemsSolved || 0, + easyCount: user.easyCount || 0, + mediumCount: user.mediumCount || 0, + hardCount: user.hardCount || 0, + realWorldCount: user.realWorldCount || 0, + rank: user.rank || 0, // Keep original rank points for reference + rankPosition: currentRank, // Proper rank position with tie handling + streak_count: user.streak_count || 0, + lastSolvedDate: user.lastSolvedDate + }); + } + + return leaderboardWithRanks.slice(0, limit); + } catch (error) { + console.error('[UserStatsService Error] Failed to get leaderboard:', error); + throw error; + } + } + + /** + * Update user stats when a problem is solved + * @param {string} userId - User ID + * @param {string} difficulty - Problem difficulty + * @param {string} category - Problem category + * @returns {Object} Updated user stats + */ + async updateUserOnProblemSolved(userId, difficulty, category) { + try { + const rankPoints = { + 'easy': 10, + 'medium': 25, + 'hard': 50, + 'real-world': 30 // Real-world projects get medium-high points + }; + + const points = rankPoints[difficulty] || 10; + + // Prepare update object + const updateObj = { + $inc: { + rank: points, + problemsSolved: 1 + }, + $set: { + lastSolvedDate: new Date() + } + }; + + // Increment difficulty-specific counter + if (difficulty === 'easy') { + updateObj.$inc.easyCount = 1; + } else if (difficulty === 'medium') { + updateObj.$inc.mediumCount = 1; + } else if (difficulty === 'hard') { + updateObj.$inc.hardCount = 1; + } else if (difficulty === 'real-world') { + updateObj.$inc.realWorldCount = 1; + } + + // Update user data + await UserDBHandler.Users.updateOne( + { userID: userId }, + updateObj, + { upsert: true } + ); + + // Handle streak separately + await this.checkAndUpdateStreak(userId); + + // Return updated user stats + return await this.getUserStats(userId); + } catch (error) { + console.error('[UserStatsService Error] updateUserOnProblemSolved failed:', error); + throw error; + } + } + + /** + * Check and update daily streak + * @param {string} userId User ID + */ + async checkAndUpdateStreak(userId) { + try { + const user = await UserDBHandler.Users.findOne({ userID: userId }); + if (!user) return; + + const today = new Date(); + const lastSolved = user.lastSolvedDate ? new Date(user.lastSolvedDate) : null; + + if (lastSolved) { + const daysDiff = Math.floor((today - lastSolved) / (1000 * 60 * 60 * 24)); + + if (daysDiff === 1) { + // Continue streak + await UserDBHandler.Users.updateOne( + { userID: userId }, + { + $inc: { streak_count: 1 }, + $set: { lastSolvedDate: today } + } + ); + } else if (daysDiff > 1) { + // Reset streak + await UserDBHandler.Users.updateOne( + { userID: userId }, + { + $set: { + streak_count: 1, + lastSolvedDate: today + } + } + ); + } + // If daysDiff === 0, user already solved today, don't update streak + } else { + // First time solving + await UserDBHandler.Users.updateOne( + { userID: userId }, + { + $set: { + streak_count: 1, + lastSolvedDate: today + } + } + ); + } + } catch (error) { + console.error('[UserStatsService Error] checkAndUpdateStreak failed:', error); + } + } + + /** + * Increment or set streak count (for compatibility with existing endpoint) + * @param {string} userId User ID + * @param {number} value Value to increment or set + * @param {boolean} set Whether to set (true) or increment (false) + */ + async incrementStreakCount(userId, value, set = false) { + try { + if (set) { + await UserDBHandler.Users.updateOne({ userID: userId }, { $set: { streak_count: value } }); + } else { + await UserDBHandler.Users.updateOne({ userID: userId }, { $inc: { streak_count: value } }); + } + } catch (error) { + console.error('[UserStatsService Error] incrementStreakCount failed:', error); + throw error; + } + } + + /** + * API endpoint to get user stats + */ + async endpoint_getUserStats(req, res) { + try { + const userId = req.auth?.userId; + if (!userId) { + return res.status(401).json({ success: false, error: 'Authentication required' }); + } + + const stats = await this.getCombinedUserStats(userId); + res.json({ success: true, stats }); + } catch (error) { + console.error('[UserStatsService] endpoint_getUserStats error:', error); + res.status(500).json({ success: false, error: 'Failed to get user stats' }); + } + } + + /** + * API endpoint to get arena stats only + */ + async endpoint_getArenaStats(req, res) { + try { + const userId = req.auth?.userId; + if (!userId) { + return res.status(401).json({ success: false, error: 'Authentication required' }); + } + + const arenaStats = await this.getArenaStats(userId); + res.json({ success: true, stats: arenaStats }); + } catch (error) { + console.error('[UserStatsService] endpoint_getArenaStats error:', error); + res.status(500).json({ success: false, error: 'Failed to get arena stats' }); + } + } + + /** + * API endpoint to get leaderboard + */ + async endpoint_getLeaderboard(req, res) { + try { + const leaderboard = await this.getLeaderboard(); + res.json({ success: true, leaderboard }); + } catch (error) { + console.error('[UserStatsService] endpoint_getLeaderboard error:', error); + res.status(500).json({ success: false, error: 'Failed to get leaderboard' }); + } + } + + /** + * API endpoint to get arena leaderboard + */ + async endpoint_getArenaLeaderboard(req, res) { + try { + const arenaLeaderboard = await this.getArenaLeaderboard(); + res.json({ success: true, leaderboard: arenaLeaderboard }); + } catch (error) { + console.error('[UserStatsService] endpoint_getArenaLeaderboard error:', error); + res.status(500).json({ success: false, error: 'Failed to get arena leaderboard' }); + } + } + + /** + * API endpoint to update problem solved stats + */ + async endpoint_updateOnProblemSolved(req, res) { + try { + const { difficulty, category } = req.body; + const userId = req.auth?.userId; + + if (!userId) { + return res.status(401).json({ success: false, error: 'Authentication required' }); + } + + if (!difficulty || !category) { + return res.status(400).json({ success: false, error: 'Difficulty and category are required' }); + } + + const updatedStats = await this.updateUserOnProblemSolved(userId, difficulty, category); + res.json({ success: true, stats: updatedStats }); + } catch (error) { + console.error('[UserStatsService] endpoint_updateOnProblemSolved error:', error); + res.status(500).json({ success: false, error: 'Failed to update user stats' }); + } + } + + /** + * API endpoint to increment streak count + */ + async endpoint_incrementStreakCount(req, res) { + try { + const { value, set } = req.params; + const userId = req.auth?.userId; + + if (!userId) { + return res.status(401).json({ success: false, error: 'Authentication required' }); + } + + const updateValue = parseInt(value) || 0; + const updateObj = set === '1' ? + { $set: { streak_count: updateValue } } : + { $inc: { streak_count: updateValue } }; + + await UserDBHandler.Users.updateOne( + { userID: userId }, + updateObj + ); + + const updatedStats = await this.getUserStats(userId); + res.json({ success: true, stats: updatedStats }); + } catch (error) { + console.error('[UserStatsService] endpoint_incrementStreakCount error:', error); + res.status(500).json({ success: false, error: 'Failed to update streak count' }); + } + } + + /** + * Migrate existing users to include realWorldCount field + */ + async migrateUsersForRealWorld() { + try { + const result = await UserDBHandler.Users.updateMany( + { realWorldCount: { $exists: false } }, + { $set: { realWorldCount: 0 } } + ); + + console.log(`[UserStatsService] Migrated ${result.modifiedCount} users with realWorldCount field`); + return result.modifiedCount; + } catch (error) { + console.error('[UserStatsService Error] Migration failed:', error); + throw error; + } + } + + /** + * Get arena statistics for a user + * @param {string} userId - User ID + * @returns {Object} Arena stats object + */ + async getArenaStats(userId) { + try { + const arenaStats = await this.ArenaPlayerStats.findOne({ userId }); + + if (!arenaStats) { + return { + totalMatches: 0, + wins: 0, + losses: 0, + draws: 0, + winRate: 0, + averageScore: 0, + totalScore: 0, + questionsCompleted: 0, + currentStreak: 0, + bestStreak: 0, + easyWins: 0, + mediumWins: 0, + hardWins: 0, + lastMatchAt: null + }; + } + + return { + totalMatches: arenaStats.totalMatches || 0, + wins: arenaStats.wins || 0, + losses: arenaStats.losses || 0, + draws: arenaStats.draws || 0, + winRate: arenaStats.winRate || 0, + averageScore: arenaStats.averageScore || 0, + totalScore: arenaStats.totalScore || 0, + questionsCompleted: arenaStats.questionsCompleted || 0, + currentStreak: arenaStats.currentStreak || 0, + bestStreak: arenaStats.bestStreak || 0, + easyWins: arenaStats.easyWins || 0, + mediumWins: arenaStats.mediumWins || 0, + hardWins: arenaStats.hardWins || 0, + lastMatchAt: arenaStats.lastMatchAt + }; + } catch (error) { + console.error('[UserStatsService Error] Failed to get arena stats:', error); + throw error; + } + } + + /** + * Get combined user stats (problems + arena) including rank position + * @param {string} userId - User ID + * @returns {Object} Combined stats object with rank position + */ + async getCombinedUserStats(userId) { + try { + const [problemStatsWithRank, arenaStats] = await Promise.all([ + this.getUserStatsWithRankPosition(userId), + this.getArenaStats(userId) + ]); + + return { + // Problem solving stats + problemsSolved: problemStatsWithRank.problemsSolved, + easyCount: problemStatsWithRank.easyCount, + mediumCount: problemStatsWithRank.mediumCount, + hardCount: problemStatsWithRank.hardCount, + realWorldCount: problemStatsWithRank.realWorldCount, + rank: problemStatsWithRank.rank, // Accumulated rank points + rankPosition: problemStatsWithRank.rankPosition, // Actual leaderboard position + streak_count: problemStatsWithRank.streak_count, + lastSolvedDate: problemStatsWithRank.lastSolvedDate, + + // Arena stats + arena: arenaStats + }; + } catch (error) { + console.error('[UserStatsService Error] Failed to get combined stats:', error); + throw error; + } + } + + /** + * Update arena stats when a match is completed + * @param {Object} matchData - Match data with player results + * @returns {Object} Updated arena stats for both players + */ + async updateArenaStats(matchData) { + try { + const { player1, player2, winner, endedAt } = matchData; + + const updates = []; + + // Update both players + for (const player of [player1, player2]) { + const isWinner = winner === player.userId; + const isLoser = winner && winner !== player.userId; + const isDraw = !winner; + + // Ensure player has username - get from Users collection if needed + let username = player.username; + if (!username) { + const user = await UserDBHandler.Users.findOne({ userID: player.userId }); + username = user?.name || `User_${player.userId.slice(-8)}`; + } + + const updateObj = { + $inc: { + totalMatches: 1, + totalScore: player.score || 0, + totalBonusPoints: player.bonusPoints || 0, + questionsCompleted: player.questionsCompleted || 0 + }, + $set: { + username: username, // Ensure username is always set + lastMatchAt: endedAt || new Date(), + updatedAt: new Date() + } + }; + + // Handle win/loss/draw + if (isWinner) { + updateObj.$inc.wins = 1; + updateObj.$inc.currentStreak = 1; + + // Increment difficulty-specific wins + const difficultyWinField = `${player.selectedDifficulty}Wins`; + updateObj.$inc[difficultyWinField] = 1; + } else if (isLoser) { + updateObj.$inc.losses = 1; + updateObj.$set.currentStreak = 0; + } else if (isDraw) { + updateObj.$inc.draws = 1; + } + + // Update player stats + const result = await this.ArenaPlayerStats.updateOne( + { userId: player.userId }, + updateObj, + { upsert: true } + ); + + // Calculate derived stats + await this.updateArenaDerivedStats(player.userId); + + updates.push({ + userId: player.userId, + updated: result.modifiedCount > 0 || result.upsertedCount > 0 + }); + } + + return updates; + } catch (error) { + console.error('[UserStatsService Error] updateArenaStats failed:', error); + throw error; + } + } + + /** + * Update derived arena stats (averages, win rate, best streak) + * @param {string} userId - User ID + */ + async updateArenaDerivedStats(userId) { + try { + const stats = await this.ArenaPlayerStats.findOne({ userId }); + if (!stats) return; + + const updates = {}; + + // Calculate averages + if (stats.totalMatches > 0) { + updates.averageScore = Math.round((stats.totalScore / stats.totalMatches) * 100) / 100; + updates.winRate = Math.round((stats.wins / stats.totalMatches) * 10000) / 100; // 2 decimal places + } + + // Update best streak + if (stats.currentStreak > stats.bestStreak) { + updates.bestStreak = stats.currentStreak; + } + + // Apply updates if any + if (Object.keys(updates).length > 0) { + await this.ArenaPlayerStats.updateOne( + { userId }, + { $set: updates } + ); + } + } catch (error) { + console.error('[UserStatsService Error] updateArenaDerivedStats failed:', error); + throw error; + } + } + + /** + * Get arena leaderboard + * @param {number} limit - Number of users to return + * @returns {Array} Array of arena stats sorted by wins + */ + async getArenaLeaderboard(limit = 50) { + try { + const leaderboard = await this.ArenaPlayerStats.find({}) + .sort({ wins: -1, winRate: -1, totalScore: -1 }) + .limit(limit) + .select('userId username totalMatches wins losses draws winRate averageScore currentStreak bestStreak'); + + return leaderboard.map(stats => ({ + userId: stats.userId, + username: stats.username, + totalMatches: stats.totalMatches, + wins: stats.wins, + losses: stats.losses, + draws: stats.draws, + winRate: stats.winRate, + averageScore: stats.averageScore, + currentStreak: stats.currentStreak, + bestStreak: stats.bestStreak + })); + } catch (error) { + console.error('[UserStatsService Error] Failed to get arena leaderboard:', error); + throw error; + } + } + + /** + * Clean up stale arena matches and enforce 30-minute time limit + * This prevents zombie matches and enforces the core arena rule: all matches end in 30 minutes max + */ + async cleanupStaleMatches() { + try { + const ArenaMatch = mongoose.model("ArenaMatch"); + const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); + + // Find all matches that are still active but started more than 30 minutes ago + const staleMatches = await ArenaMatch.find({ + status: { $in: ['waiting', 'in_progress'] }, + startedAt: { $lt: thirtyMinutesAgo } + }); + + let cleanedCount = 0; + + for (const match of staleMatches) { + try { + // Calculate final scores based on current progress + const player1Score = match.player1.score || 0; + const player2Score = match.player2.score || 0; + + // Determine winner based on scores at timeout + let winner = null; + if (player1Score > player2Score) { + winner = match.player1.userId; + } else if (player2Score > player1Score) { + winner = match.player2.userId; + } + // If equal scores, it's a draw (winner stays null) + + // End the match due to timeout + const updateResult = await ArenaMatch.updateOne( + { matchId: match.matchId }, + { + $set: { + status: 'completed', + endedAt: new Date(), + endReason: 'timeout', + totalDuration: 30 * 60, // 30 minutes in seconds + winner: winner, + 'player1.finalScore': player1Score, + 'player2.finalScore': player2Score, + updatedAt: new Date() + } + } + ); + + if (updateResult.modifiedCount > 0) { + cleanedCount++; + + // Update player arena stats for both players + const matchData = { + player1: { + userId: match.player1.userId, + username: match.player1.username, + selectedDifficulty: match.player1.selectedDifficulty, + score: player1Score, + questionsCompleted: match.player1.questionsCompleted || 0, + bonusPoints: 0 + }, + player2: { + userId: match.player2.userId, + username: match.player2.username, + selectedDifficulty: match.player2.selectedDifficulty, + score: player2Score, + questionsCompleted: match.player2.questionsCompleted || 0, + bonusPoints: 0 + }, + winner: winner, + endedAt: new Date() + }; + + // Update arena stats + await this.updateArenaStats(matchData); + + console.log(`✅ [UserStatsService] Match ${match.matchId} ended due to 30-minute timeout. Winner: ${winner || 'Draw'}`); + } + } catch (matchError) { + console.error(`❌ [UserStatsService] Error ending match ${match.matchId}:`, matchError); + } + } + + if (cleanedCount > 0) { + console.log(`[UserStatsService] Cleaned up ${cleanedCount} matches that exceeded 30-minute limit`); + } + + return cleanedCount; + } catch (error) { + console.error('[UserStatsService Error] Failed to cleanup stale matches:', error); + return 0; + } + } + + /** + * Get arena system statistics with automatic 30-minute timeout enforcement + * @returns {Object} Arena system stats + */ + async getArenaSystemStats() { + try { + // Always run cleanup to enforce 30-minute rule (30% chance to reduce server load) + if (Math.random() < 0.3) { + await this.cleanupStaleMatches(); + } + + // If we have arena socket handler, use its real-time stats + if (this.arenaSocketHandler) { + const arenaStats = await this.arenaSocketHandler.getArenaStats(); + return { + onlinePlayersCount: arenaStats.onlineUsers || 0, + activeMatchesCount: arenaStats.activeMatches || 0, + totalMatchesCount: arenaStats.totalMatches || 0 + }; + } + + // Fallback to database queries if no socket handler + const ArenaQueue = mongoose.model("ArenaQueue"); + const onlinePlayersCount = await ArenaQueue.countDocuments({}); + + // Get active matches count (only in_progress, to match ArenaSocketHandler) + const ArenaMatch = mongoose.model("ArenaMatch"); + const activeMatchesCount = await ArenaMatch.countDocuments({ + status: 'in_progress' + }); + + // Get total completed matches + const totalMatchesCount = await ArenaMatch.countDocuments({ + status: 'completed' + }); + + return { + onlinePlayersCount, + activeMatchesCount, + totalMatchesCount + }; + } catch (error) { + console.error('[UserStatsService Error] Failed to get arena system stats:', error); + return { + onlinePlayersCount: 0, + activeMatchesCount: 0, + totalMatchesCount: 0 + }; + } + } + + /** + * API endpoint to get arena system stats + */ + async endpoint_getArenaSystemStats(req, res) { + try { + const stats = await this.getArenaSystemStats(); + res.json({ success: true, stats }); + } catch (error) { + console.error('[UserStatsService] endpoint_getArenaSystemStats error:', error); + res.status(500).json({ success: false, error: 'Failed to get arena system stats' }); + } + } + + /** + * Get user's rank position in the leaderboard + * @param {string} userId - User ID + * @returns {number} User's rank position (1-based) + */ + async getUserRankPosition(userId) { + try { + // First get the target user's stats + const targetUser = await UserDBHandler.Users.findOne({ userID: userId }) + .select('rank problemsSolved streak_count'); + + if (!targetUser) { + return 0; // User not found + } + + const targetRank = targetUser.rank || 0; + const targetProblems = targetUser.problemsSolved || 0; + const targetStreak = targetUser.streak_count || 0; + + // Get all users with better stats (ordered the same way as leaderboard) + const betterUsers = await UserDBHandler.Users.find({ + $or: [ + // Users with higher rank points + { rank: { $gt: targetRank } }, + + // Users with same rank points but more problems solved + { + rank: targetRank, + problemsSolved: { $gt: targetProblems } + }, + + // Users with same rank points and problems solved but higher streak + { + rank: targetRank, + problemsSolved: targetProblems, + streak_count: { $gt: targetStreak } + } + ] + }).sort({ + rank: -1, + problemsSolved: -1, + streak_count: -1, + userID: 1 + }); + + // Count users with identical stats (ties) that come before this user (by userID) + const tiedUsers = await UserDBHandler.Users.find({ + rank: targetRank, + problemsSolved: targetProblems, + streak_count: targetStreak, + userID: { $lt: userId } // Users with lexicographically smaller userID come first + }).countDocuments(); + + // Return 1-based position (1st place = 1, 2nd place = 2, etc.) + return betterUsers.length + tiedUsers + 1; + } catch (error) { + console.error('[UserStatsService Error] Failed to get user rank position:', error); + return 0; + } + } + + /** + * Get comprehensive user statistics including leaderboard rank position + * @param {string} userId - User ID + * @returns {Object} User stats object with rank position + */ + async getUserStatsWithRankPosition(userId) { + try { + const [basicStats, rankPosition] = await Promise.all([ + this.getUserStats(userId), + this.getUserRankPosition(userId) + ]); + + return { + ...basicStats, + rankPosition: rankPosition // Actual position in leaderboard (1st, 2nd, 3rd, etc.) + }; + } catch (error) { + console.error('[UserStatsService Error] Failed to get user stats with rank position:', error); + throw error; + } + } + + /** + * API endpoint to get user's rank position in leaderboard + */ + async endpoint_getUserRankPosition(req, res) { + try { + const userId = req.auth?.userId; + if (!userId) { + return res.status(401).json({ success: false, error: 'Authentication required' }); + } + + const rankPosition = await this.getUserRankPosition(userId); + res.json({ success: true, rankPosition }); + } catch (error) { + console.error('[UserStatsService] endpoint_getUserRankPosition error:', error); + res.status(500).json({ success: false, error: 'Failed to get user rank position' }); + } + } +} + +// Export the class and let the main server create the instance with dependencies +export { UserStatsService };