document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('login-form'); const submitBtn = document.getElementById('submit-btn'); const btnText = submitBtn.querySelector('.btn-text'); const loader = submitBtn.querySelector('.loader'); const loginView = document.getElementById('login-view-container'); const dashboardView = document.getElementById('dashboard-view'); const errorMessage = document.getElementById('error-message'); const errorText = document.getElementById('error-text'); const logoutBtn = document.getElementById('logout-btn'); // Pages const homePage = document.getElementById('home-page'); const searchPage = document.getElementById('search-page'); const tabHome = document.getElementById('tab-home'); const tabSearch = document.getElementById('tab-search'); // Session state let currentSessionId = null; let currentUsername = null; // ========== TAB SWITCHING ========== document.getElementById('nav-tabs').addEventListener('click', (e) => { const tab = e.target.closest('.nav-tab'); if (!tab) return; const targetTab = tab.dataset.tab; // Update active tab styles document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); // Show/hide pages if (targetTab === 'home') { homePage.classList.remove('hidden'); searchPage.classList.add('hidden'); } else if (targetTab === 'search') { homePage.classList.add('hidden'); searchPage.classList.remove('hidden'); } }); // ========== LOGIN ========== form.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('username').value.trim(); const password = document.getElementById('password').value; if (!username || !password) return; setLoading(true); errorMessage.classList.add('hidden'); try { const formData = new FormData(); formData.append('username', username); formData.append('password', password); const response = await fetch('/api/login', { method: 'POST', body: formData }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.detail || `Server error: ${response.status}`); } if (data.success && data['ASP.NET_SessionId']) { currentSessionId = data['ASP.NET_SessionId']; currentUsername = username; loginView.classList.add('hidden'); dashboardView.classList.remove('hidden'); // Start fetch marks process fetchMarks(username, currentSessionId); } else { throw new Error("Login succeeded but no session cookie was returned."); } } catch (error) { console.error("Login error:", error); errorText.textContent = error.message || "Failed to login. Please try again."; errorMessage.classList.remove('hidden'); const newError = errorMessage.cloneNode(true); errorMessage.parentNode.replaceChild(newError, errorMessage); } finally { setLoading(false); } }); // ========== LOGOUT ========== logoutBtn.addEventListener('click', () => { dashboardView.classList.add('hidden'); loginView.classList.remove('hidden'); form.reset(); currentSessionId = null; currentUsername = null; // Reset to home tab tabHome.click(); // Clear search results document.getElementById('search-cards-container').innerHTML = ''; document.getElementById('search-cards-container').classList.add('hidden'); document.getElementById('username').focus(); }); // ========== FETCH MARKS (HOME) ========== async function fetchMarks(username, sessionId) { const loading = document.getElementById('dashboard-loading'); const container = document.getElementById('cards-container'); const errorMsg = document.getElementById('dashboard-error'); const userDisplay = document.getElementById('user-display'); const circle = document.querySelector('.progress-ring__circle:not(.search-ring-circle)'); const percentageText = document.getElementById('progress-percentage'); const progressStatus = document.getElementById('progress-text'); const radius = circle.r.baseVal.value; const circumference = radius * 2 * Math.PI; circle.style.strokeDasharray = `${circumference} ${circumference}`; circle.style.strokeDashoffset = circumference; userDisplay.textContent = `${username}`; loading.classList.remove('hidden'); container.classList.add('hidden'); errorMsg.classList.add('hidden'); function setProgress(percent) { const offset = circumference - percent / 100 * circumference; circle.style.strokeDashoffset = offset; percentageText.textContent = Math.round(percent); } setProgress(0); progressStatus.textContent = 'Connecting to SIMATS...'; let progress = 0; const progressInterval = setInterval(() => { const remaining = 99 - progress; progress += (Math.random() * 0.1 + 0.05) * remaining; setProgress(progress); if (progress > 30 && progress < 60) { progressStatus.textContent = 'Fetching course enrollments...'; } else if (progress >= 60 && progress < 85) { progressStatus.textContent = 'Aggregating detailed mark splits...'; } else if (progress >= 85) { progressStatus.textContent = 'Finalizing data format...'; } }, 500); try { const formData = new FormData(); formData.append('username', username); formData.append('session_id', sessionId); const response = await fetch('/api/fetch-marks', { method: 'POST', body: formData }); const data = await response.json().catch(() => ({})); clearInterval(progressInterval); if (!response.ok) throw new Error(data.detail || 'Failed to fetch marks'); setProgress(100); progressStatus.textContent = 'Data loaded successfully!'; setTimeout(() => { try { loading.classList.add('hidden'); renderCards(data.data, container); container.classList.remove('hidden'); } catch (renderError) { console.error("Rendering error:", renderError); loading.classList.add('hidden'); errorMsg.textContent = "An error occurred while displaying your marks. " + renderError.message; errorMsg.classList.remove('hidden'); } }, 800); } catch (e) { clearInterval(progressInterval); loading.classList.add('hidden'); errorMsg.textContent = e.message; errorMsg.classList.remove('hidden'); container.innerHTML = ''; } } // ========== SEARCH STUDENT ========== const searchForm = document.getElementById('search-form'); const searchSubmitBtn = document.getElementById('search-submit-btn'); searchForm.addEventListener('submit', async (e) => { e.preventDefault(); const registerNo = document.getElementById('search-register').value.trim(); if (!registerNo) return; if (!currentSessionId) { alert('Session expired. Please log in again.'); return; } const searchBtnText = searchSubmitBtn.querySelector('.btn-text'); const searchBtnLoader = searchSubmitBtn.querySelector('.loader'); const searchError = document.getElementById('search-error'); const searchLoading = document.getElementById('search-loading'); const searchContainer = document.getElementById('search-cards-container'); // Setup search loading ring const searchCircle = document.querySelector('.search-ring-circle'); const searchPercentText = document.getElementById('search-progress-percentage'); const searchProgressStatus = document.getElementById('search-progress-text'); const radius = searchCircle.r.baseVal.value; const circumference = radius * 2 * Math.PI; searchCircle.style.strokeDasharray = `${circumference} ${circumference}`; searchCircle.style.strokeDashoffset = circumference; function setSearchProgress(percent) { const offset = circumference - percent / 100 * circumference; searchCircle.style.strokeDashoffset = offset; searchPercentText.textContent = Math.round(percent); } // UI state searchSubmitBtn.disabled = true; searchBtnText.classList.add('hidden'); searchBtnLoader.classList.remove('hidden'); searchError.classList.add('hidden'); searchContainer.classList.add('hidden'); searchContainer.innerHTML = ''; searchLoading.classList.remove('hidden'); setSearchProgress(0); searchProgressStatus.textContent = 'Searching student records...'; let progress = 0; const progressInterval = setInterval(() => { const remaining = 95 - progress; // Faster increment for a snappier UI progress += (Math.random() * 0.12 + 0.05) * remaining; setSearchProgress(progress); if (progress > 20 && progress < 50) { searchProgressStatus.textContent = 'Scanning course databases...'; } else if (progress >= 50 && progress < 80) { searchProgressStatus.textContent = 'Matching register number...'; } else if (progress >= 80) { searchProgressStatus.textContent = 'Compiling results...'; } }, 300); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 75000); // 75s timeout try { const formData = new FormData(); formData.append('register_no', registerNo); formData.append('session_id', currentSessionId); const response = await fetch('/api/search-student', { method: 'POST', body: formData, signal: controller.signal }); clearTimeout(timeoutId); const data = await response.json().catch(() => ({})); clearInterval(progressInterval); if (!response.ok) throw new Error(data.detail || 'Search failed'); setSearchProgress(100); searchProgressStatus.textContent = 'Search complete!'; setTimeout(() => { searchLoading.classList.add('hidden'); if (!data.data || data.data.length === 0) { searchError.innerHTML = ` No results found for register number: ${registerNo}`; searchError.classList.remove('hidden'); return; } // Add student badge before results const badge = document.createElement('div'); badge.style.textAlign = 'center'; badge.innerHTML = `
Results for: ${registerNo.toUpperCase()}  ยท  ${data.data.length} course(s) found
`; searchContainer.appendChild(badge); renderCards(data.data, searchContainer, true); searchContainer.classList.remove('hidden'); }, 600); } catch (err) { clearInterval(progressInterval); searchLoading.classList.add('hidden'); searchError.innerHTML = ` ${err.message}`; searchError.classList.remove('hidden'); } finally { searchSubmitBtn.disabled = false; searchBtnText.classList.remove('hidden'); searchBtnLoader.classList.add('hidden'); } }); // ========== RENDER CARDS (shared between Home & Search) ========== function renderCards(data, container, isSearch = false) { if (!data || data.length === 0) { container.innerHTML = '
No course enrollments found for this period.
'; return; } let totalGradePoints = 0; let totalCredits = 0; let validCourses = 0; const gradeValues = { 'S': 10, 'A': 9, 'B': 8, 'C': 7, 'D': 6, 'E': 5 }; let gradeCounts = { 'S': 0, 'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0 }; data.forEach((item, index) => { const card = document.createElement('div'); card.className = 'course-card'; card.style.opacity = '0'; card.animate([ { opacity: 0, transform: 'translateY(30px) scale(0.95)' }, { opacity: 1, transform: 'translateY(0) scale(1)' } ], { duration: 500, easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)', fill: 'forwards', delay: index * 100 }); const c = item.course; const marks = item.marks; const error = item.error; const grade = c.FinalGrade ? c.FinalGrade.trim().toUpperCase() : ''; if (gradeValues[grade] !== undefined) { const credit = parseFloat(c.Credit || c.Credits || 1); totalGradePoints += gradeValues[grade] * credit; totalCredits += credit; validCourses++; if (gradeCounts[grade] !== undefined) { gradeCounts[grade]++; } } let marksHtml = ''; let totalObtained = 0; if (error) { marksHtml = `
${error}
`; } else if (marks && marks.length > 0) { const targetCategories = ["Class Test (IA)", "Research", "Class Practical", "University Theory", "University Practical"]; marks.forEach(m => { const category = m.RubricCategory ? m.RubricCategory.trim() : ""; if (targetCategories.includes(category)) { const val = parseFloat(m.OrginalConvertedMark); if (!isNaN(val)) { totalObtained += val; } } }); totalObtained = Math.round(totalObtained * 100) / 100; const list = marks.map(m => { const isPass = m.IsPassed; const statusClass = isPass ? 'mark-pass' : 'mark-fail'; const statusText = isPass ? 'PASS' : 'FAIL'; return `
  • ${m.RubricCategory} ${m.Type}
    ${m.OrginalConvertedMark} / ${m.RubricsMaxMark}
    ${statusText}
  • `; }).join(''); marksHtml = ``; } else { marksHtml = `
    No marks available for this course
    `; } card.innerHTML = `
    ${c.CourseCode || '-'}

    ${c.CourseName || 'Unknown Course'}

    ${c.MonthYearValue || '-'}
    ${marks && !error ? `Total: ${totalObtained}/500` : ''} Grade: ${c.FinalGrade || 'N/A'}
    ${marksHtml}
    `; container.appendChild(card); }); if (validCourses > 0 && totalCredits > 0) { const cgpa = (totalGradePoints / totalCredits).toFixed(2); const cgpaCard = document.createElement('div'); cgpaCard.className = 'course-card'; cgpaCard.style.opacity = '0'; cgpaCard.style.gridColumn = '1 / -1'; cgpaCard.style.background = 'var(--card-bg)'; cgpaCard.style.border = '2px solid rgba(255, 255, 255, 0.4)'; cgpaCard.style.textAlign = 'center'; cgpaCard.style.marginBottom = '20px'; cgpaCard.animate([ { opacity: 0, transform: 'translateY(-20px)' }, { opacity: 1, transform: 'translateY(0)' } ], { duration: 500, easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)', fill: 'forwards', delay: 0 }); // Use unique IDs for search vs home to avoid conflicts const prefix = isSearch ? 'search-calc' : 'home-calc'; cgpaCard.innerHTML = `

    Overall CGPA

    ${cgpa}

    Courses Completed

    ${validCourses}

    Target CGPA Planner

    `; container.insertBefore(cgpaCard, container.firstChild); const updateCalc = () => { const target = parseFloat(document.getElementById(`${prefix}-target`).value); const futureGp = parseFloat(document.getElementById(`${prefix}-grade`).value); const resultDiv = document.getElementById(`${prefix}-result`); const curr = totalGradePoints / totalCredits; if (isNaN(target) || isNaN(futureGp)) return; if (curr >= target) { resultDiv.innerHTML = `Already has a ${target.toFixed(1)}+ CGPA!`; return; } if (futureGp <= target) { resultDiv.innerHTML = `Impossible to reach a ${target.toFixed(1)} CGPA by scoring ${futureGp} points or less.`; return; } const selectEl = document.getElementById(`${prefix}-grade`); const gradeName = selectEl.options[selectEl.selectedIndex].text; const reqCredits = (target * totalCredits - totalGradePoints) / (futureGp - target); resultDiv.innerHTML = `Need ${Math.ceil(reqCredits)} more credits of ${gradeName} grade to reach a ${target.toFixed(1)} CGPA.`; }; document.getElementById(`${prefix}-target`).addEventListener('input', updateCalc); document.getElementById(`${prefix}-grade`).addEventListener('change', updateCalc); updateCalc(); // Grade Distribution Chart const totalGradesCount = Object.values(gradeCounts).reduce((a, b) => a + b, 0); if (totalGradesCount > 0 && typeof Chart !== 'undefined') { const chartId = isSearch ? 'searchChart' : 'performanceChart'; const graphCard = document.createElement('div'); graphCard.className = 'course-card'; graphCard.style.opacity = '0'; graphCard.style.gridColumn = '1 / -1'; graphCard.style.background = 'var(--card-bg)'; graphCard.style.border = '1px solid rgba(255, 255, 255, 0.2)'; graphCard.style.padding = '30px'; graphCard.style.marginTop = '20px'; graphCard.animate([ { opacity: 0, transform: 'translateY(30px) scale(0.95)' }, { opacity: 1, transform: 'translateY(0) scale(1)' } ], { duration: 500, easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)', fill: 'forwards', delay: (data.length + 1) * 100 }); graphCard.innerHTML = `

    Grade Distribution

    `; container.appendChild(graphCard); const pieLabels = []; const pieData = []; const pieColors = []; const ctx = document.getElementById(chartId).getContext('2d'); const createGrad = (c1, c2) => { const g = ctx.createLinearGradient(0, 0, 0, 400); g.addColorStop(0, c1); g.addColorStop(1, c2); return g; }; const colorPalette = { 'S': createGrad('rgba(255, 215, 0, 1)', 'rgba(218, 165, 32, 0.6)'), 'A': createGrad('rgba(52, 211, 153, 1)', 'rgba(5, 150, 105, 0.6)'), 'B': createGrad('rgba(96, 165, 250, 1)', 'rgba(37, 99, 235, 0.6)'), 'C': createGrad('rgba(167, 139, 250, 1)', 'rgba(124, 58, 237, 0.6)'), 'D': createGrad('rgba(251, 146, 60, 1)', 'rgba(234, 88, 12, 0.6)'), 'E': createGrad('rgba(248, 113, 113, 1)', 'rgba(220, 38, 38, 0.6)') }; for (const g in gradeCounts) { if (gradeCounts[g] > 0) { pieLabels.push(g); pieData.push(gradeCounts[g]); pieColors.push(colorPalette[g] || '#888'); } } const shadowPlugin = { id: 'shadowPlugin_' + chartId, beforeDatasetDraw: (chart) => { const ctx = chart.ctx; ctx.save(); ctx.shadowColor = 'rgba(0, 0, 0, 0.4)'; ctx.shadowBlur = 15; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 8; }, afterDatasetDraw: (chart) => { chart.ctx.restore(); } }; const centerTextPlugin = { id: 'centerText_' + chartId, afterDraw: (chart) => { if (chart.data.datasets.length === 0) return; const ctx = chart.ctx; const meta = chart.getDatasetMeta(0); if (!meta.data || !meta.data.length) return; const center = meta.data[0]; const x = center.x; const y = center.y; ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = "bold 54px 'Outfit', sans-serif"; ctx.fillStyle = "#ffffff"; ctx.shadowColor = 'rgba(255,255,255,0.1)'; ctx.shadowBlur = 10; ctx.fillText(totalGradesCount.toString(), x, y - 8); ctx.font = "400 13px 'Outfit', sans-serif"; ctx.fillStyle = "#a1a1aa"; ctx.shadowBlur = 0; ctx.letterSpacing = "1px"; ctx.fillText("TOTAL GRADES", x, y + 28); ctx.restore(); } }; new Chart(ctx, { type: 'doughnut', data: { labels: pieLabels, datasets: [{ data: pieData, backgroundColor: pieColors, borderColor: 'transparent', borderWidth: 0, hoverOffset: 12, borderRadius: 10, spacing: 8 }] }, plugins: [shadowPlugin, centerTextPlugin], options: { cutout: '78%', responsive: true, maintainAspectRatio: false, layout: { padding: 20 }, animation: { animateScale: true, animateRotate: true, duration: 1500, easing: 'easeOutQuart' }, plugins: { legend: { position: 'right', labels: { color: '#d1d5db', font: { size: 14, family: "'Outfit', sans-serif", weight: '500' }, usePointStyle: true, pointStyle: 'circle', padding: 25 } }, tooltip: { backgroundColor: 'rgba(15, 15, 20, 0.95)', titleFont: { size: 15, family: "'Outfit', sans-serif", weight: '600' }, bodyFont: { size: 14, family: "'Outfit', sans-serif" }, padding: 15, cornerRadius: 12, displayColors: true, usePointStyle: true, borderColor: 'rgba(255, 255, 255, 0.2)', borderWidth: 1, boxPadding: 6 } } } }); } } } function setLoading(isLoading) { submitBtn.disabled = isLoading; if (isLoading) { btnText.classList.add('hidden'); loader.classList.remove('hidden'); } else { btnText.classList.remove('hidden'); loader.classList.add('hidden'); } } });