From bf8f291f000bafd799a5c4b75e4c6f728e9a66df Mon Sep 17 00:00:00 2001 From: Vachhani-Tapan Date: Thu, 4 Jun 2026 22:39:58 +0530 Subject: [PATCH] feat(leaderboard): add peer comparison mode and side-by-side performance charts --- frontend/js/compare.js | 435 ++++++++++++++++++++++++++++++++++++++ frontend/leaderboard.html | 212 +++++++++++++------ frontend/styles/main.css | 292 +++++++++++++++++++++++++ 3 files changed, 878 insertions(+), 61 deletions(-) create mode 100644 frontend/js/compare.js diff --git a/frontend/js/compare.js b/frontend/js/compare.js new file mode 100644 index 00000000..d90729eb --- /dev/null +++ b/frontend/js/compare.js @@ -0,0 +1,435 @@ +// --- Leaderboard Comparison Feature JS --- + +let selectedStudents = new Set(); +let isCompareMode = false; +let diffChartInstance = null; +let historyChartInstance = null; + +// Colors for comparison +const COMPARE_COLORS = { + 0: { border: '#00ff41', background: 'rgba(0, 255, 65, 0.25)', label: 'USER_A' }, + 1: { border: '#00e5ff', background: 'rgba(0, 229, 255, 0.25)', label: 'USER_B' }, + 2: { border: '#ffb000', background: 'rgba(255, 176, 0, 0.25)', label: 'USER_C' } +}; + +// Global hooks to inject into the rendering loop +function initCompareMode() { + // Bind compare mode toggle + const toggleBtn = document.getElementById('compare-mode-toggle'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + isCompareMode = !isCompareMode; + if (isCompareMode) { + toggleBtn.classList.add('active'); + toggleBtn.innerText = 'COMPARE_MODE [ON]'; + document.querySelector('.leaderboard')?.classList.add('compare-active'); + } else { + toggleBtn.classList.remove('active'); + toggleBtn.innerText = 'COMPARE_MODE [OFF]'; + document.querySelector('.leaderboard')?.classList.remove('compare-active'); + selectedStudents.clear(); + updateFloatingBar(); + } + applyFiltersAndRender(); + }); + } + + // Create Floating Action Bar + createFloatingBar(); + + // Create Modal Structure + createCompareModal(); +} + +function createFloatingBar() { + if (document.getElementById('compare-floating-bar')) return; + + const bar = document.createElement('div'); + bar.id = 'compare-floating-bar'; + bar.className = 'compare-floating-bar'; + bar.innerHTML = ` +
[SYS_LOAD]: 0/3 PEERS SELECTED
+ + `; + document.body.appendChild(bar); + + document.getElementById('compare-submit-btn')?.addEventListener('click', () => { + if (selectedStudents.size >= 2 && selectedStudents.size <= 3) { + openCompareModal(); + } + }); +} + +function createCompareModal() { + if (document.getElementById('compare-modal-overlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'compare-modal-overlay'; + overlay.className = 'compare-modal-overlay'; + overlay.innerHTML = ` +
+
+
[SYS_EXEC]: COMPILING PEER METRICS
+ +
+
+ +
+
+ `; + document.body.appendChild(overlay); + + document.getElementById('compare-modal-close-btn')?.addEventListener('click', closeCompareModal); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeCompareModal(); + }); +} + +function updateFloatingBar() { + const bar = document.getElementById('compare-floating-bar'); + const info = document.getElementById('compare-bar-info'); + if (!bar || !info) return; + + const count = selectedStudents.size; + info.innerText = `[SYS_LOAD]: ${count}/3 PEERS SELECTED`; + + if (count >= 2 && count <= 3) { + bar.classList.add('show'); + } else { + bar.classList.remove('show'); + } +} + +function handleSelectCheckbox(username, checked) { + if (checked) { + if (selectedStudents.size >= 3) { + alert('[SYS_WARN]: MAXIMUM LIMIT EXCEEDED (MAX 3 USERS FOR COMPARISON)'); + return false; // prevent checking + } + selectedStudents.add(username); + } else { + selectedStudents.delete(username); + } + updateFloatingBar(); + return true; +} + +// Fetch stats and render modal +async function openCompareModal() { + const overlay = document.getElementById('compare-modal-overlay'); + const content = document.getElementById('compare-modal-content'); + if (!overlay || !content) return; + + overlay.classList.add('show'); + content.innerHTML = ` +
+
+ [SYS_CONN]: INITIALIZING UPLINK AND PARSING ARCHIVES... +
+
Retrieving student metadata logs...
+
+ `; + + const usernames = Array.from(selectedStudents); + + // Find current data of selected users from leaderboardData + const activeData = leaderboardData[activeDatasetType] || []; + const selectedUserData = usernames.map(username => activeData.find(u => u.id === username)).filter(Boolean); + + try { + // Concurrently fetch histories from the API endpoint + const historyPromises = usernames.map(username => + fetch(`/api/student/${username}`) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .catch(err => { + console.error(`Failed to fetch history for ${username}`, err); + return { username, history: [] }; + }) + ); + + const historyResults = await Promise.all(historyPromises); + + // Build side-by-side comparison table HTML + let tableHtml = ` + + + + + `; + + selectedUserData.forEach((user, index) => { + const colorInfo = COMPARE_COLORS[index]; + tableHtml += ``; + }); + + tableHtml += ` + + + + + + `; + selectedUserData.forEach(user => { + tableHtml += ``; + }); + + tableHtml += ` + + + + `; + selectedUserData.forEach(user => { + tableHtml += ``; + }); + + tableHtml += ` + + + + `; + selectedUserData.forEach(user => { + tableHtml += ``; + }); + + tableHtml += ` + + + + `; + selectedUserData.forEach(user => { + tableHtml += ``; + }); + + tableHtml += ` + + + + `; + selectedUserData.forEach(user => { + tableHtml += ``; + }); + + tableHtml += ` + + + + `; + selectedUserData.forEach((user, index) => { + const history = historyResults[index]?.history || []; + const totalDays = history.length; + if (totalDays > 0) { + const sum = history.reduce((acc, curr) => acc + (curr.easy + curr.medium + curr.hard), 0); + const avg = (sum / totalDays).toFixed(1); + tableHtml += ``; + } else { + tableHtml += ``; + } + }); + + tableHtml += ` + + +
Metric${user.name} (${user.id})
Overall Rank#${user.originalRank || '-'}
Total Score${user.score}
Easy Solved${user.data.easySolved}
Medium Solved${user.data.mediumSolved}
Hard Solved${user.data.hardSolved}
Average Cumulative Solves${avg} / day-
+ `; + + // Inject charts container + content.innerHTML = ` + ${tableHtml} +
+
+
Difficulty Breakdown
+
+ +
+
+
+
Grinding Velocity (Cumulative History)
+
+ +
+
+
+ `; + + // Render Charts using Chart.js + renderComparisonCharts(selectedUserData, historyResults); + + } catch (err) { + content.innerHTML = ` +
+ [SYS_ERROR]: PIPELINE_FAILED_TO_RENDER_COMPARISON +
+ ${err.message} +
+ `; + } +} + +function closeCompareModal() { + const overlay = document.getElementById('compare-modal-overlay'); + if (overlay) overlay.classList.remove('show'); + + // Destroy chart instances to release memory + if (diffChartInstance) { + diffChartInstance.destroy(); + diffChartInstance = null; + } + if (historyChartInstance) { + historyChartInstance.destroy(); + historyChartInstance = null; + } +} + +function renderComparisonCharts(users, historyResults) { + // Chart.js Global Font Config + Chart.defaults.font.family = "'Fira Code', 'Courier New', monospace"; + Chart.defaults.font.size = 11; + Chart.defaults.color = '#5a8a5a'; // --text-dim + + // 1. Difficulty Breakdown (Bar Chart) + const diffCtx = document.getElementById('compare-diff-canvas')?.getContext('2d'); + if (diffCtx) { + const datasets = users.map((user, index) => { + const colors = COMPARE_COLORS[index]; + return { + label: user.name, + data: [user.data.easySolved, user.data.mediumSolved, user.data.hardSolved], + backgroundColor: colors.background, + borderColor: colors.border, + borderWidth: 1, + barPercentage: 0.6, + categoryPercentage: 0.8 + }; + }); + + diffChartInstance = new Chart(diffCtx, { + type: 'bar', + data: { + labels: ['Easy', 'Medium', 'Hard'], + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + grid: { color: 'rgba(0, 255, 65, 0.08)' }, + ticks: { color: '#5a8a5a' } + }, + y: { + grid: { color: 'rgba(0, 255, 65, 0.08)' }, + ticks: { color: '#5a8a5a', stepSize: 20 }, + beginAtZero: true + } + }, + plugins: { + legend: { + labels: { color: '#b0ffb0' } + }, + tooltip: { + backgroundColor: '#0a0a0a', + titleColor: '#00ff41', + bodyColor: '#b0ffb0', + borderColor: 'rgba(0, 255, 65, 0.3)', + borderWidth: 1, + cornerRadius: 0 + } + } + } + }); + } + + // 2. Grinding Velocity Cumulative History (Line Chart) + const historyCtx = document.getElementById('compare-history-canvas')?.getContext('2d'); + if (historyCtx) { + // We want to combine dates. Let's find all unique dates across all user histories + const allDatesSet = new Set(); + historyResults.forEach(res => { + res.history.forEach(day => allDatesSet.add(day.date)); + }); + + // Convert to sorted array + const sortedDates = Array.from(allDatesSet).sort((a, b) => new Date(a) - new Date(b)); + + // Map datasets + const datasets = users.map((user, index) => { + const colors = COMPARE_COLORS[index]; + const history = historyResults[index]?.history || []; + + // Fill in data points matching each sorted date + const dataPoints = sortedDates.map(date => { + // Find entry for date, or fallback to previous cumulative solves + const entry = history.find(day => day.date === date); + if (entry) { + return entry.easy + entry.medium + entry.hard; + } + // If not found, let's find the closest previous date solves + const previousEntries = history.filter(day => new Date(day.date) < new Date(date)); + if (previousEntries.length > 0) { + // Sort descending and get the first (latest before date) + previousEntries.sort((a, b) => new Date(b.date) - new Date(a.date)); + return previousEntries[0].easy + previousEntries[0].medium + previousEntries[0].hard; + } + return 0; // default start + }); + + return { + label: user.name, + data: dataPoints, + borderColor: colors.border, + backgroundColor: colors.background, + borderWidth: 2, + tension: 0.1, + pointBackgroundColor: colors.border, + pointBorderColor: '#0a0a0a', + pointRadius: 3, + pointHoverRadius: 5 + }; + }); + + // Format dates to MMM DD for terminal labels + const formattedLabels = sortedDates.map(dateStr => { + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }).toUpperCase(); + }); + + historyChartInstance = new Chart(historyCtx, { + type: 'line', + data: { + labels: formattedLabels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + grid: { color: 'rgba(0, 255, 65, 0.08)' }, + ticks: { color: '#5a8a5a', maxRotation: 45, minRotation: 45 } + }, + y: { + grid: { color: 'rgba(0, 255, 65, 0.08)' }, + ticks: { color: '#5a8a5a' }, + beginAtZero: true + } + }, + plugins: { + legend: { + labels: { color: '#b0ffb0' } + }, + tooltip: { + backgroundColor: '#0a0a0a', + titleColor: '#00ff41', + bodyColor: '#b0ffb0', + borderColor: 'rgba(0, 255, 65, 0.3)', + borderWidth: 1, + cornerRadius: 0 + } + } + } + }); + } +} diff --git a/frontend/leaderboard.html b/frontend/leaderboard.html index 242c6f83..de5f1b77 100644 --- a/frontend/leaderboard.html +++ b/frontend/leaderboard.html @@ -13,6 +13,8 @@ + + @@ -71,25 +73,28 @@

Leaderboard

-
-
+
Rank
Name
LeetCode ID
@@ -187,6 +192,7 @@

Leaderboard

} }); + initCompareMode(); fetchLeaderboardData(); }); @@ -330,6 +336,56 @@

Leaderboard

| Page: ${currentPage}/${totalPages}
`; + // Update header dynamically + const header = document.getElementById("leaderboard-table-header"); + if (header) { + if (typeof isCompareMode !== 'undefined' && isCompareMode) { + header.innerHTML = ` +
Select
+
Rank
+
Name
+
LeetCode ID
+
Easy
+
Medium
+
Hard
+
+ Score +
+ ? +
+ Score Calculation:
+ Easy = 1 point
+ Medium = 3 points
+ Hard = 5 points
+ More details +
+
+
+ `; + } else { + header.innerHTML = ` +
Rank
+
Name
+
LeetCode ID
+
Easy
+
Medium
+
Hard
+
+ Score +
+ ? +
+ Score Calculation:
+ Easy = 1 point
+ Medium = 3 points
+ Hard = 5 points
+ More details +
+
+
+ `; + } + } renderLeaderboard(filteredData); } function getRankTag(rank) { @@ -389,7 +445,14 @@

Leaderboard

const hardScore = user.data.hardSolved * hardPoints; const row = document.createElement("div"); row.className = "leaderboard-row"; + + let compareCell = ""; + if (typeof isCompareMode !== 'undefined' && isCompareMode) { + compareCell = `
`; + } + row.innerHTML = ` + ${compareCell}
${rank}
${tag}${user.name}
${user.id} @@ -427,59 +490,86 @@

Leaderboard

body.appendChild(row); const card = document.createElement("div"); - card.className = "mobile-card"; - card.innerHTML = ` -
-
#${rank}
-
- ${user.score} - -
-
- Easy: ${user.data.easySolved} × ${easyPoints} = ${easyScore} -
-
- Medium: ${user.data.mediumSolved} × ${mediumPoints} = ${mediumScore} -
-
- Hard: ${user.data.hardSolved} × ${hardPoints} = ${hardScore} -
-
-
- Total: ${user.score} -
-
-
-
+ let mobileCompareCell = ""; + if (typeof isCompareMode !== 'undefined' && isCompareMode) { + mobileCompareCell = `
`; + card.className = "mobile-card compare-mode-active"; + } else { + card.className = "mobile-card"; + } - -
${tag}${user.name}
-
-
-
-
${user.data.easySolved}
-
Easy
-
-
-
${user.data.mediumSolved}
-
Medium
-
-
-
${user.data.hardSolved}
-
Hard
-
+ card.innerHTML = ` + ${mobileCompareCell} +
+
+
#${rank}
+
+ ${user.score} + +
+
+ Easy: ${user.data.easySolved} × ${easyPoints} = ${easyScore} +
+
+ Medium: ${user.data.mediumSolved} × ${mediumPoints} = ${mediumScore} +
+
+ Hard: ${user.data.hardSolved} × ${hardPoints} = ${hardScore} +
+
+
+ Total: ${user.score} +
+
+
+
+
${tag}${user.name}
+ +
+
+
${user.data.easySolved}
+
Easy
+
+
+
${user.data.mediumSolved}
+
Medium
+
+
+
${user.data.hardSolved}
+
Hard
+
+
`; mobileCards.appendChild(card); }); + + // Bind change event to checkboxes + if (typeof isCompareMode !== 'undefined' && isCompareMode) { + document.querySelectorAll(".compare-checkbox").forEach((cb) => { + cb.addEventListener("change", (e) => { + const username = cb.dataset.username; + const success = handleSelectCheckbox(username, cb.checked); + if (!success) { + cb.checked = false; + } + // Sync checkbox checks between desktop row and mobile card + document.querySelectorAll(`.compare-checkbox[data-username="${username}"]`).forEach((otherCb) => { + if (otherCb !== cb) { + otherCb.checked = cb.checked; + } + }); + }); + }); + } renderPagination(data.length); } function renderPagination(totalItems) { diff --git a/frontend/styles/main.css b/frontend/styles/main.css index 34aba592..29d0e2c5 100644 --- a/frontend/styles/main.css +++ b/frontend/styles/main.css @@ -2460,3 +2460,295 @@ body::-webkit-scrollbar-thumb { .active-page::after { display: none; } + +/* ========================================================================== + Leaderboard Comparison Feature Styles + ========================================================================== */ + +/* Compare columns layout for leaderboard rows */ +.leaderboard.compare-active .leaderboard-header { + grid-template-columns: 50px 55px 1fr 170px 65px 65px 65px 85px; +} +.leaderboard.compare-active .leaderboard-row { + grid-template-columns: 50px 55px 1fr 170px 65px 65px 65px 85px; +} + +.compare-col-header, .compare-cell { + display: flex; + align-items: center; + justify-content: center; +} + +/* Cyberpunk terminal checkboxes */ +.compare-checkbox { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + border: 1px solid var(--green-dim); + background: var(--bg); + cursor: pointer; + position: relative; + transition: all 0.15s ease; +} + +.compare-checkbox:checked { + background: var(--green-muted); + border-color: var(--green); + box-shadow: 0 0 8px var(--green); +} + +.compare-checkbox:checked::after { + content: "X"; + color: var(--green); + font-family: "Space Mono", monospace; + font-size: 10px; + font-weight: bold; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.compare-checkbox:focus { + outline: none; +} + +/* Mobile comparison checkbox container */ +.mobile-compare-checkbox-container { + display: flex; + align-items: center; + justify-content: center; + padding-right: 12px; + border-right: 1px solid var(--border); + margin-right: 12px; +} + +/* --- Floating Compare Action Bar --- */ +.compare-floating-bar { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(150%); + width: calc(100% - 40px); + max-width: 500px; + background: rgba(10, 10, 10, 0.95); + border: 2px solid var(--cyan); + box-shadow: 0 0 20px rgba(0, 229, 255, 0.25), inset 0 0 15px rgba(0, 229, 255, 0.05); + border-radius: 4px; + padding: 0.8rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 1000; + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.compare-floating-bar.show { + transform: translateX(-50%) translateY(0); +} + +.compare-info { + font-family: "Fira Code", monospace; + font-size: 0.85rem; + color: var(--cyan); + text-shadow: 0 0 5px rgba(0, 229, 255, 0.3); +} + +.compare-btn { + background: var(--cyan); + color: #000; + border: 1px solid var(--cyan); + border-radius: 3px; + padding: 0.4rem 1.2rem; + font-family: "Space Mono", monospace; + font-weight: 700; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.compare-btn:hover { + background: transparent; + color: var(--cyan); + box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); +} + +/* --- Comparison Modal Overlay --- */ +.compare-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 10000; + display: none; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.compare-modal-overlay.show { + display: flex; +} + +.compare-modal { + width: 100%; + max-width: 950px; + background: var(--bg-surface); + border: 1px solid var(--border-bright); + border-radius: 8px; + box-shadow: 0 0 40px rgba(0, 255, 65, 0.1); + overflow: hidden; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: fadeIn 0.3s ease-out; +} + +.compare-modal-header { + background: var(--bg-raised); + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--border-bright); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.compare-modal-title { + font-family: "Space Mono", monospace; + font-size: 1.1rem; + color: var(--green); + text-shadow: 0 0 10px rgba(0, 255, 65, 0.3); +} + +.compare-modal-close { + background: transparent; + border: none; + color: var(--text-dim); + font-family: "Fira Code", monospace; + font-size: 1.2rem; + cursor: pointer; + padding: 2px 8px; + transition: color 0.15s ease; +} + +.compare-modal-close:hover { + color: var(--red); + text-shadow: 0 0 5px var(--red); +} + +.compare-modal-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +/* Comparison Table style */ +.compare-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 2rem; + font-size: 0.88rem; +} + +.compare-table th, .compare-table td { + border: 1px solid var(--border); + padding: 0.75rem 1rem; + text-align: center; +} + +.compare-table th { + background: var(--bg-raised); + color: var(--green-dim); + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; +} + +.compare-table td:first-child { + font-weight: 700; + text-align: left; + background: rgba(0, 255, 65, 0.02); + color: var(--text-dim); +} + +/* Charts container */ +.compare-charts-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-top: 1rem; +} + +@media (max-width: 768px) { + .compare-charts-grid { + grid-template-columns: 1fr; + } +} + +.chart-card { + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1.25rem; +} + +.chart-title { + font-family: "Space Mono", monospace; + font-size: 0.9rem; + margin-bottom: 1rem; + color: var(--green-dim); + border-left: 3px solid var(--green); + padding-left: 8px; +} + +.chart-container { + position: relative; + height: 250px; + width: 100%; +} + +/* Toggle Compare Button */ +.compare-toggle-btn { + background: transparent; + color: var(--amber); + border: 1px solid var(--amber-dim); + border-radius: 4px; + padding: 0.55rem 1rem; + font-family: "Fira Code", monospace; + font-weight: 600; + font-size: 0.82rem; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.compare-toggle-btn:hover { + background: var(--amber-muted); + box-shadow: 0 0 10px rgba(255, 176, 0, 0.2); +} + +.compare-toggle-btn.active { + color: var(--green); + border-color: var(--green-dim); + background: var(--green-muted); + box-shadow: 0 0 10px rgba(0, 255, 65, 0.2); +} + +/* Controls header container style helper */ +.controls-row { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 420px; + margin: 0 auto 2rem auto; + gap: 12px; +} + +@media (max-width: 580px) { + .controls-row { + flex-direction: column; + align-items: stretch; + } +} +