From 9354e1e58ba421f051e1398b6510bee8928cf26a Mon Sep 17 00:00:00 2001 From: Vachhani-Tapan Date: Fri, 5 Jun 2026 22:49:59 +0530 Subject: [PATCH 1/2] Add side-by-side peer comparison feature to leaderboard with interactive charts --- frontend/js/leaderboard/compare.js | 451 +++++++++++++++++++++++++++++ frontend/js/leaderboard/render.js | 36 +++ frontend/leaderboard.html | 73 ++++- frontend/styles/main.css | 272 +++++++++++++++++ server.js | 6 +- 5 files changed, 832 insertions(+), 6 deletions(-) create mode 100644 frontend/js/leaderboard/compare.js diff --git a/frontend/js/leaderboard/compare.js b/frontend/js/leaderboard/compare.js new file mode 100644 index 00000000..3b665d38 --- /dev/null +++ b/frontend/js/leaderboard/compare.js @@ -0,0 +1,451 @@ +// Peer Comparison Module + +window.selectedCompareUsers = []; +let difficultyChartInstance = null; +let historyChartInstance = null; + +// Initialize on load +document.addEventListener("DOMContentLoaded", () => { + setupCompareListeners(); +}); + +function setupCompareListeners() { + const compareBtn = document.getElementById("compare-btn"); + const modal = document.getElementById("compare-modal"); + const overlay = modal.querySelector(".modal-overlay"); + const closeBtns = modal.querySelectorAll(".close-modal-btn"); + + if (compareBtn) { + compareBtn.addEventListener("click", openCompareModal); + } + + if (overlay) { + overlay.addEventListener("click", closeCompareModal); + } + + closeBtns.forEach((btn) => { + btn.addEventListener("click", closeCompareModal); + }); + + // Handle ESC key to close modal + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !modal.classList.contains("hidden")) { + closeCompareModal(); + } + }); +} + +// Handle checkbox selection change (exposed to render.js) +window.handleCompareSelectionChange = function (user, isChecked) { + if (isChecked) { + if (window.selectedCompareUsers.length < 3) { + // Ensure we don't have duplicate + if (!window.selectedCompareUsers.some((u) => u.id === user.id)) { + window.selectedCompareUsers.push(user); + } + } + } else { + window.selectedCompareUsers = window.selectedCompareUsers.filter( + (u) => u.id !== user.id, + ); + } + + updateCheckboxesState(); + updateFloatingBar(); +}; + +function updateCheckboxesState() { + const checkboxes = document.querySelectorAll(".compare-checkbox"); + const count = window.selectedCompareUsers.length; + + checkboxes.forEach((cb) => { + const username = cb.dataset.username; + const isSelected = window.selectedCompareUsers.some( + (u) => u.id === username, + ); + cb.checked = isSelected; + + if (count >= 3 && !isSelected) { + cb.disabled = true; + } else { + cb.disabled = false; + } + }); +} + +function updateFloatingBar() { + const bar = document.getElementById("compare-floating-bar"); + const countSpan = document.getElementById("compare-count"); + const namesDiv = document.getElementById("compare-selected-names"); + const compareBtn = document.getElementById("compare-btn"); + + const count = window.selectedCompareUsers.length; + + if (countSpan) { + countSpan.textContent = count; + } + + if (namesDiv) { + namesDiv.innerHTML = ""; + window.selectedCompareUsers.forEach((user) => { + const badge = document.createElement("span"); + badge.className = "compare-badge"; + + // Sanitize user name safely using textNode + badge.appendChild(document.createTextNode(user.name)); + + const removeBtn = document.createElement("span"); + removeBtn.className = "compare-badge-remove"; + removeBtn.textContent = " ×"; + removeBtn.addEventListener("click", () => { + window.handleCompareSelectionChange(user, false); + }); + badge.appendChild(removeBtn); + + namesDiv.appendChild(badge); + }); + } + + if (compareBtn) { + compareBtn.disabled = count < 2; + } + + if (bar) { + if (count >= 2) { + bar.classList.remove("hidden"); + } else { + bar.classList.add("hidden"); + } + } +} + +function closeCompareModal() { + const modal = document.getElementById("compare-modal"); + if (modal) { + modal.classList.add("hidden"); + } + + // Destroy charts to free resources + if (difficultyChartInstance) { + difficultyChartInstance.destroy(); + difficultyChartInstance = null; + } + if (historyChartInstance) { + historyChartInstance.destroy(); + historyChartInstance = null; + } +} + +async function openCompareModal() { + const modal = document.getElementById("compare-modal"); + const loading = document.getElementById("compare-modal-loading"); + const errorDiv = document.getElementById("compare-modal-error"); + const content = document.getElementById("compare-modal-content"); + + if (!modal) return; + + modal.classList.remove("hidden"); + loading.classList.remove("hidden"); + errorDiv.classList.add("hidden"); + content.classList.add("hidden"); + + try { + const comparedData = await Promise.all( + window.selectedCompareUsers.map(async (user) => { + try { + const res = await fetch(`/api/student/${user.id}`); + if (!res.ok) throw new Error("Fetch failed"); + const details = await res.json(); + return { + user, + history: details.history || [], + success: true, + }; + } catch (e) { + console.error(`Failed to fetch history for ${user.id}`, e); + return { + user, + history: [], + success: false, + }; + } + }), + ); + + loading.classList.add("hidden"); + content.classList.remove("hidden"); + + renderMetricsTable(comparedData); + renderDifficultyChart(comparedData); + renderHistoryChart(comparedData); + } catch (err) { + console.error("Comparison fetch error", err); + loading.classList.add("hidden"); + errorDiv.classList.remove("hidden"); + document.getElementById("compare-error-msg").textContent = err.message; + } +} + +function calculateAverageDaily(history) { + if (!history || history.length < 2) return 0; + const first = history[0]; + const last = history[history.length - 1]; + const firstTotal = first.easy + first.medium + first.hard; + const lastTotal = last.easy + last.medium + last.hard; + const diff = lastTotal - firstTotal; + const days = + (new Date(last.date) - new Date(first.date)) / (1000 * 60 * 60 * 24); + return days > 0 ? diff / days : 0; +} + +function renderMetricsTable(comparedData) { + const table = document.getElementById("compare-metrics-table"); + table.innerHTML = ""; + + // Headings Row + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + + const metricHeader = document.createElement("th"); + metricHeader.textContent = "Metric"; + headerRow.appendChild(metricHeader); + + comparedData.forEach((item) => { + const th = document.createElement("th"); + th.textContent = item.user.name; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // Define Metrics to display + const metrics = [ + { label: "LeetCode ID", key: "id", isRaw: true }, + { label: "Current Rank", key: "originalRank", isUserField: true }, + { label: "Score", key: "score", isUserField: true }, + { label: "Easy Solved", key: "easySolved", isDataField: true }, + { label: "Medium Solved", key: "mediumSolved", isDataField: true }, + { label: "Hard Solved", key: "hardSolved", isDataField: true }, + { label: "Total Solved", key: "totalSolved", isDataField: true }, + { + label: "Avg Daily Solved", + calc: (item) => calculateAverageDaily(item.history).toFixed(2), + }, + ]; + + const tbody = document.createElement("tbody"); + + metrics.forEach((metric) => { + const tr = document.createElement("tr"); + + const labelTd = document.createElement("td"); + labelTd.textContent = metric.label; + labelTd.className = "metric-label"; + tr.appendChild(labelTd); + + comparedData.forEach((item) => { + const td = document.createElement("td"); + + if (metric.isRaw) { + td.textContent = item.user[metric.key]; + } else if (metric.isUserField) { + td.textContent = item.user[metric.key]; + } else if (metric.isDataField) { + td.textContent = item.user.data[metric.key]; + } else if (metric.calc) { + td.textContent = metric.calc(item); + } + + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + + table.appendChild(tbody); +} + +function renderDifficultyChart(comparedData) { + const ctx = document.getElementById("difficultyChart").getContext("2d"); + + if (difficultyChartInstance) { + difficultyChartInstance.destroy(); + } + + // Predefined CRT Colors + const colors = [ + { + border: "#00ff41", + bg: "rgba(0, 255, 65, 0.15)", + }, + { + border: "#00e5ff", + bg: "rgba(0, 229, 255, 0.15)", + }, + { + border: "#ffb000", + bg: "rgba(255, 176, 0, 0.15)", + }, + ]; + + const datasets = comparedData.map((item, index) => { + const color = colors[index % colors.length]; + return { + label: item.user.name, + data: [ + item.user.data.easySolved, + item.user.data.mediumSolved, + item.user.data.hardSolved, + ], + backgroundColor: color.bg, + borderColor: color.border, + borderWidth: 1.5, + }; + }); + + difficultyChartInstance = new Chart(ctx, { + 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", + font: { + family: "Fira Code, Courier New, monospace", + }, + }, + }, + y: { + grid: { + color: "rgba(0, 255, 65, 0.08)", + }, + ticks: { + color: "#5a8a5a", + font: { + family: "Fira Code, Courier New, monospace", + }, + }, + }, + }, + plugins: { + legend: { + labels: { + color: "#b0ffb0", + font: { + family: "Fira Code, Courier New, monospace", + size: 11, + }, + }, + }, + }, + }, + }); +} + +function renderHistoryChart(comparedData) { + const ctx = document.getElementById("historyChart").getContext("2d"); + + if (historyChartInstance) { + historyChartInstance.destroy(); + } + + // Find all unique dates in the history sets + const allDates = new Set(); + comparedData.forEach((item) => { + item.history.forEach((h) => { + if (h.date) allDates.add(h.date); + }); + }); + + // Sort dates chronologically + const sortedDates = Array.from(allDates).sort( + (a, b) => new Date(a) - new Date(b), + ); + + // Predefined CRT colors for lines + const colors = ["#00ff41", "#00e5ff", "#ffb000"]; + + const datasets = comparedData.map((item, index) => { + const color = colors[index % colors.length]; + + // Build values corresponding to each date + let lastTotal = 0; + const dataPoints = sortedDates.map((date) => { + const record = item.history.find((r) => r.date === date); + if (record) { + lastTotal = record.easy + record.medium + record.hard; + } + return lastTotal; + }); + + return { + label: item.user.name, + data: dataPoints, + borderColor: color, + backgroundColor: "transparent", + borderWidth: 2, + tension: 0.1, + pointRadius: sortedDates.length > 15 ? 1 : 3, + pointBackgroundColor: color, + }; + }); + + historyChartInstance = new Chart(ctx, { + type: "line", + data: { + labels: sortedDates, + datasets: datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + grid: { + color: "rgba(0, 255, 65, 0.08)", + }, + ticks: { + color: "#5a8a5a", + maxRotation: 45, + minRotation: 45, + font: { + family: "Fira Code, Courier New, monospace", + size: 9, + }, + }, + }, + y: { + grid: { + color: "rgba(0, 255, 65, 0.08)", + }, + ticks: { + color: "#5a8a5a", + font: { + family: "Fira Code, Courier New, monospace", + }, + }, + }, + }, + plugins: { + legend: { + labels: { + color: "#b0ffb0", + font: { + family: "Fira Code, Courier New, monospace", + size: 11, + }, + }, + }, + }, + }, + }); +} diff --git a/frontend/js/leaderboard/render.js b/frontend/js/leaderboard/render.js index a99a8fc9..3896b979 100644 --- a/frontend/js/leaderboard/render.js +++ b/frontend/js/leaderboard/render.js @@ -85,6 +85,24 @@ function renderLeaderboardRow(user, rank) { // Name cell — tag is safe DOM element, name is user-controlled (textContent) const nameDiv = document.createElement("div"); nameDiv.className = "name-cell"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "compare-checkbox"; + checkbox.dataset.username = user.id; + if (window.selectedCompareUsers && window.selectedCompareUsers.some(u => u.id === user.id)) { + checkbox.checked = true; + } + if (window.selectedCompareUsers && window.selectedCompareUsers.length >= 3 && !window.selectedCompareUsers.some(u => u.id === user.id)) { + checkbox.disabled = true; + } + checkbox.addEventListener("change", (e) => { + if (window.handleCompareSelectionChange) { + window.handleCompareSelectionChange(user, e.target.checked); + } + }); + nameDiv.appendChild(checkbox); + if (rankTagEl) { nameDiv.appendChild(rankTagEl); } @@ -203,6 +221,24 @@ function renderMobileCard(user, rank) { // Name — tag safe DOM element, name is user-controlled (textContent) const mobileName = document.createElement("div"); mobileName.className = "mobile-name"; + + const mCheckbox = document.createElement("input"); + mCheckbox.type = "checkbox"; + mCheckbox.className = "compare-checkbox"; + mCheckbox.dataset.username = user.id; + if (window.selectedCompareUsers && window.selectedCompareUsers.some(u => u.id === user.id)) { + mCheckbox.checked = true; + } + if (window.selectedCompareUsers && window.selectedCompareUsers.length >= 3 && !window.selectedCompareUsers.some(u => u.id === user.id)) { + mCheckbox.disabled = true; + } + mCheckbox.addEventListener("change", (e) => { + if (window.handleCompareSelectionChange) { + window.handleCompareSelectionChange(user, e.target.checked); + } + }); + mobileName.appendChild(mCheckbox); + const mobileRankTagEl = createRankTagElement(rank); const mobileRankChangeEl = createRankChangeElement(user.rankChange); if (mobileRankTagEl) { diff --git a/frontend/leaderboard.html b/frontend/leaderboard.html index be7b1bcb..e1657ed8 100644 --- a/frontend/leaderboard.html +++ b/frontend/leaderboard.html @@ -14,6 +14,8 @@ + + @@ -350,9 +352,70 @@

Leaderboard

currentPage = 1; applyFiltersAndRender(); } - - - - - + + + + + + + + + + + + diff --git a/frontend/styles/main.css b/frontend/styles/main.css index 1bed409b..1bd3d4ad 100644 --- a/frontend/styles/main.css +++ b/frontend/styles/main.css @@ -2517,3 +2517,275 @@ body::-webkit-scrollbar-thumb { .rank-neutral { color: var(--text-muted); } + +/* ========================================================================== + LeetCode Comparison Styles + ========================================================================== */ + +.compare-checkbox { + accent-color: var(--green); + cursor: pointer; + width: 14px; + height: 14px; + margin-right: 8px; + border: 1px solid var(--border-bright); + background: var(--bg); + vertical-align: middle; + flex-shrink: 0; +} + +.mobile-name { + display: flex; + align-items: center; + gap: 8px; +} + +.compare-bar { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + width: calc(100% - 32px); + max-width: 650px; + background: rgba(15, 15, 15, 0.95); + border: 1px solid var(--green); + border-radius: 6px; + box-shadow: 0 0 20px rgba(0, 255, 65, 0.2), inset 0 0 10px rgba(0, 255, 65, 0.05); + backdrop-filter: blur(8px); + animation: slideUpCompare 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideUpCompare { + from { + transform: translate(-50%, 100px); + opacity: 0; + } + to { + transform: translate(-50%, 0); + opacity: 1; + } +} + +.compare-bar.hidden { + display: none !important; +} + +.compare-bar-content { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + gap: 16px; + flex-wrap: wrap; +} + +.compare-bar-text { + font-family: "Space Mono", monospace; + color: var(--text-dim); + font-size: 0.82rem; +} + +.compare-bar-text span { + color: var(--green); +} + +.compare-selected-names { + display: flex; + gap: 8px; + flex-wrap: wrap; + flex: 1; +} + +.compare-badge { + background: rgba(0, 255, 65, 0.1); + border: 1px solid var(--green-dim); + color: var(--green); + padding: 2px 8px; + border-radius: 3px; + font-size: 0.78rem; + display: inline-flex; + align-items: center; + gap: 6px; + font-family: "Fira Code", monospace; +} + +.compare-badge-remove { + cursor: pointer; + color: var(--text-muted); + transition: color 0.15s ease; +} + +.compare-badge-remove:hover { + color: var(--red); +} + +/* Modal and comparison details */ +.modal { + position: fixed; + inset: 0; + z-index: 1050; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.modal.hidden { + display: none !important; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(5, 5, 5, 0.85); + backdrop-filter: blur(4px); +} + +.modal-container { + position: relative; + width: 100%; + max-width: 850px; + max-height: 90vh; + background: var(--bg-surface); + border: 1px solid var(--border-bright); + border-radius: 8px; + box-shadow: 0 0 35px rgba(0, 255, 65, 0.15); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalAppear 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes modalAppear { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.modal-header { + background: var(--bg-raised); + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); +} + +.close-modal-x { + background: transparent; + border: none; + color: var(--text-dim); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + transition: color 0.15s ease; +} + +.close-modal-x:hover { + color: var(--red); +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; + background: #000; +} + +.compare-loading, .compare-error { + font-family: "Fira Code", monospace; + padding: 40px; + text-align: center; + color: var(--green-dim); +} + +.compare-error { + color: var(--red); +} + +.compare-loading .loading-cursor { + animation: blink 1s step-end infinite; +} + +/* Comparison metrics table */ +.table-responsive { + overflow-x: auto; + margin-bottom: 1.5rem; + border: 1px solid var(--border); + border-radius: 4px; +} + +.comparison-table { + width: 100%; + border-collapse: collapse; + font-family: "Fira Code", monospace; + font-size: 0.85rem; + text-align: left; +} + +.comparison-table th, .comparison-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + border-right: 1px solid var(--border); +} + +.comparison-table th:last-child, .comparison-table td:last-child { + border-right: none; +} + +.comparison-table tr:last-child td { + border-bottom: none; +} + +.comparison-table th { + background: var(--bg-raised); + color: var(--green); + font-weight: 700; +} + +.comparison-table td.metric-label { + color: var(--text-dim); + font-weight: 600; + background: rgba(0, 255, 65, 0.02); +} + +/* Charts styling */ +.comparison-charts-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 768px) { + .comparison-charts-grid { + grid-template-columns: 1fr; + } + + .compare-bar-content { + padding: 8px 12px; + gap: 8px; + } + + .compare-bar { + bottom: 12px; + } +} + +.chart-container-wrapper { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 15px; +} + +.chart-holder { + position: relative; + height: 250px; + width: 100%; +} diff --git a/server.js b/server.js index 3fa8ddd2..63805b4f 100644 --- a/server.js +++ b/server.js @@ -34,7 +34,11 @@ app.use( ], // Inline scripts need a per-request nonce; external scripts from 'self' // are allowed automatically. - scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`], + scriptSrc: [ + "'self'", + "https://cdn.jsdelivr.net", + (req, res) => `'nonce-${res.locals.nonce}'`, + ], // Allow inline styles (style attributes) + Google Fonts stylesheet styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], // Google Fonts From ff7c54cd8181412991bdef254262546e9790768c Mon Sep 17 00:00:00 2001 From: Vachhani-Tapan Date: Sun, 7 Jun 2026 15:57:30 +0530 Subject: [PATCH 2/2] feat: Add peer comparison feature, live daily stats, and fix global rank & history fetching bugs --- frontend/js/leaderboard/compare.js | 219 ++++++++++++++++++++++++++++- frontend/js/leaderboard/render.js | 2 + frontend/leaderboard.html | 18 ++- frontend/styles/main.css | 114 +++++++++++++++ scripts/fetch-student-info.js | 35 +++-- 5 files changed, 368 insertions(+), 20 deletions(-) diff --git a/frontend/js/leaderboard/compare.js b/frontend/js/leaderboard/compare.js index 3b665d38..3f4f9dbe 100644 --- a/frontend/js/leaderboard/compare.js +++ b/frontend/js/leaderboard/compare.js @@ -1,6 +1,7 @@ // Peer Comparison Module window.selectedCompareUsers = []; +window.compareModeEnabled = false; let difficultyChartInstance = null; let historyChartInstance = null; @@ -11,9 +12,53 @@ document.addEventListener("DOMContentLoaded", () => { function setupCompareListeners() { const compareBtn = document.getElementById("compare-btn"); + const resetBtn = document.getElementById("compare-reset-btn"); const modal = document.getElementById("compare-modal"); const overlay = modal.querySelector(".modal-overlay"); const closeBtns = modal.querySelectorAll(".close-modal-btn"); + const toggleBtn = document.getElementById("compare-mode-toggle"); + + if (toggleBtn) { + toggleBtn.addEventListener("click", () => { + window.compareModeEnabled = !window.compareModeEnabled; + if (window.compareModeEnabled) { + document.body.classList.add("compare-mode-active"); + toggleBtn.textContent = "$ compare_mode --disable"; + toggleBtn.classList.remove("btn-secondary"); + toggleBtn.classList.add("btn-primary"); + document.querySelectorAll(".compare-checkbox").forEach(cb => cb.style.display = "inline-block"); + } else { + document.body.classList.remove("compare-mode-active"); + toggleBtn.textContent = "$ compare_mode --enable"; + toggleBtn.classList.remove("btn-primary"); + toggleBtn.classList.add("btn-secondary"); + document.querySelectorAll(".compare-checkbox").forEach(cb => cb.style.display = "none"); + // Clear selection + window.selectedCompareUsers = []; + updateCheckboxesState(); + updateFloatingBar(); + } + }); + } + + if (resetBtn) { + resetBtn.addEventListener("click", () => { + // Clear selection + window.selectedCompareUsers = []; + updateCheckboxesState(); + updateFloatingBar(); + + // Exit comparison mode + window.compareModeEnabled = false; + document.body.classList.remove("compare-mode-active"); + if (toggleBtn) { + toggleBtn.textContent = "$ compare_mode --enable"; + toggleBtn.classList.remove("btn-primary"); + toggleBtn.classList.add("btn-secondary"); + } + document.querySelectorAll(".compare-checkbox").forEach(cb => cb.style.display = "none"); + }); + } if (compareBtn) { compareBtn.addEventListener("click", openCompareModal); @@ -35,6 +80,13 @@ function setupCompareListeners() { }); } +// Global helper to clear comparison selections from parent tab switching +window.clearCompareSelection = function () { + window.selectedCompareUsers = []; + updateCheckboxesState(); + updateFloatingBar(); +}; + // Handle checkbox selection change (exposed to render.js) window.handleCompareSelectionChange = function (user, isChecked) { if (isChecked) { @@ -70,6 +122,9 @@ function updateCheckboxesState() { } else { cb.disabled = false; } + + // Ensure display state matches comparison mode + cb.style.display = window.compareModeEnabled ? "inline-block" : "none"; }); } @@ -150,22 +205,52 @@ async function openCompareModal() { content.classList.add("hidden"); try { + // Set dynamic metric summary title based on active tab + const titleEl = document.getElementById("compare-metrics-title"); + if (titleEl) { + const context = window.activeDatasetType || "overall"; + titleEl.textContent = `> metrics_summary (${context})`; + } + const comparedData = await Promise.all( window.selectedCompareUsers.map(async (user) => { try { - const res = await fetch(`/api/student/${user.id}`); + let apiUrL = `/api/student/${user.id}`; + if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") { + if (window.location.port && window.location.port !== "3000") { + apiUrL = `http://localhost:3000/api/student/${user.id}`; + } + } + const res = await fetch(apiUrL); if (!res.ok) throw new Error("Fetch failed"); const details = await res.json(); + + let liveData = null; + if (window.activeDatasetType === "daily") { + try { + const liveRes = await fetch(`https://leetcode-api-dun.vercel.app/${user.id}`); + if (liveRes.ok) { + liveData = await liveRes.json(); + } + } catch (liveErr) { + console.error(`Failed to fetch live stats for ${user.id}`, liveErr); + } + } + return { user, history: details.history || [], + globalRank: details.globalRank || null, + liveData, success: true, }; } catch (e) { - console.error(`Failed to fetch history for ${user.id}`, e); + console.error(`Failed to fetch student details for ${user.id}`, e); return { user, history: [], + globalRank: null, + liveData: null, success: false, }; } @@ -221,7 +306,21 @@ function renderMetricsTable(comparedData) { // Define Metrics to display const metrics = [ { label: "LeetCode ID", key: "id", isRaw: true }, - { label: "Current Rank", key: "originalRank", isUserField: true }, + { label: "Leaderboard Rank", key: "originalRank", isUserField: true }, + { + label: "Rank Change", + calc: (item) => { + const rc = item.user.rankChange; + if (!rc) return "N/A"; + if (rc === "NEW") return "[new]"; + if (rc === "=") return "[==]"; + return `[${rc}]`; + } + }, + { + label: "Global Rank", + calc: (item) => item.globalRank ? item.globalRank.toLocaleString() : "N/A" + }, { label: "Score", key: "score", isUserField: true }, { label: "Easy Solved", key: "easySolved", isDataField: true }, { label: "Medium Solved", key: "mediumSolved", isDataField: true }, @@ -352,7 +451,37 @@ function renderDifficultyChart(comparedData) { } function renderHistoryChart(comparedData) { - const ctx = document.getElementById("historyChart").getContext("2d"); + const chartTitleEl = document.getElementById("history-chart-title"); + const chartCanvas = document.getElementById("historyChart"); + const dailyPlaceholder = document.getElementById("daily-comparison-placeholder"); + + const context = window.activeDatasetType || "overall"; + + if (chartTitleEl) { + if (context === "daily") { + chartTitleEl.textContent = "> daily_comparison"; + } else { + chartTitleEl.textContent = `> progress_history (${context})`; + } + } + + // Handle daily tab fallback to live stats instead of line chart + if (context === "daily") { + if (chartCanvas) chartCanvas.classList.add("hidden"); + if (dailyPlaceholder) dailyPlaceholder.classList.remove("hidden"); + if (historyChartInstance) { + historyChartInstance.destroy(); + historyChartInstance = null; + } + renderDailyComparison(comparedData); + return; + } + + // Show chart canvas and hide daily placeholder for other tabs + if (chartCanvas) chartCanvas.classList.remove("hidden"); + if (dailyPlaceholder) dailyPlaceholder.classList.add("hidden"); + + const ctx = chartCanvas.getContext("2d"); if (historyChartInstance) { historyChartInstance.destroy(); @@ -367,18 +496,37 @@ function renderHistoryChart(comparedData) { }); // Sort dates chronologically - const sortedDates = Array.from(allDates).sort( + let sortedDates = Array.from(allDates).sort( (a, b) => new Date(a) - new Date(b), ); + // Slices dates based on context window + if (context === "weekly") { + sortedDates = sortedDates.slice(-7); + } else if (context === "monthly") { + sortedDates = sortedDates.slice(-30); + } + // Predefined CRT colors for lines const colors = ["#00ff41", "#00e5ff", "#ffb000"]; const datasets = comparedData.map((item, index) => { const color = colors[index % colors.length]; - // Build values corresponding to each date + // Find baseline total solved count just before the first visible date let lastTotal = 0; + if (sortedDates.length > 0) { + const firstVisibleDate = new Date(sortedDates[0]); + const recordsBefore = item.history + .filter(r => new Date(r.date) < firstVisibleDate) + .sort((a, b) => new Date(b.date) - new Date(a.date)); + if (recordsBefore.length > 0) { + const latestBefore = recordsBefore[0]; + lastTotal = latestBefore.easy + latestBefore.medium + latestBefore.hard; + } + } + + // Build values corresponding to each date const dataPoints = sortedDates.map((date) => { const record = item.history.find((r) => r.date === date); if (record) { @@ -449,3 +597,62 @@ function renderHistoryChart(comparedData) { }, }); } + +function renderDailyComparison(comparedData) { + const placeholder = document.getElementById("daily-comparison-placeholder"); + if (!placeholder) return; + placeholder.innerHTML = ""; + + const textDiv = document.createElement("div"); + textDiv.className = "daily-placeholder-text"; + textDiv.textContent = "[!] No meaningful historical trend available for daily comparison."; + placeholder.appendChild(textDiv); + + const cardsContainer = document.createElement("div"); + cardsContainer.className = "live-stats-cards"; + + comparedData.forEach((item) => { + const card = document.createElement("div"); + card.className = "live-stats-card"; + + const title = document.createElement("h4"); + title.appendChild(document.createTextNode(item.user.name)); + card.appendChild(title); + + const makeRow = (label, val) => { + const row = document.createElement("div"); + row.className = "live-stat-row"; + const lbl = document.createElement("span"); + lbl.textContent = label; + row.appendChild(lbl); + const value = document.createElement("span"); + value.textContent = val; + row.appendChild(value); + return row; + }; + + if (item.liveData) { + card.appendChild(makeRow("Easy Solved", item.liveData.easySolved || 0)); + card.appendChild(makeRow("Medium Solved", item.liveData.mediumSolved || 0)); + card.appendChild(makeRow("Hard Solved", item.liveData.hardSolved || 0)); + card.appendChild(makeRow("Live Total", item.liveData.totalSolved || 0)); + } else { + // Fallback to local daily leaderboard dataset values + card.appendChild(makeRow("Easy Solved", item.user.data.easySolved || 0)); + card.appendChild(makeRow("Medium Solved", item.user.data.mediumSolved || 0)); + card.appendChild(makeRow("Hard Solved", item.user.data.hardSolved || 0)); + card.appendChild(makeRow("Total Solved", item.user.data.totalSolved || 0)); + + const noteRow = document.createElement("div"); + noteRow.style.fontSize = "0.65rem"; + noteRow.style.color = "var(--text-muted)"; + noteRow.style.marginTop = "8px"; + noteRow.textContent = "* Offline profile data"; + card.appendChild(noteRow); + } + + cardsContainer.appendChild(card); + }); + + placeholder.appendChild(cardsContainer); +} diff --git a/frontend/js/leaderboard/render.js b/frontend/js/leaderboard/render.js index 3896b979..c3ba6306 100644 --- a/frontend/js/leaderboard/render.js +++ b/frontend/js/leaderboard/render.js @@ -89,6 +89,7 @@ function renderLeaderboardRow(user, rank) { const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.className = "compare-checkbox"; + checkbox.style.display = window.compareModeEnabled ? "inline-block" : "none"; checkbox.dataset.username = user.id; if (window.selectedCompareUsers && window.selectedCompareUsers.some(u => u.id === user.id)) { checkbox.checked = true; @@ -225,6 +226,7 @@ function renderMobileCard(user, rank) { const mCheckbox = document.createElement("input"); mCheckbox.type = "checkbox"; mCheckbox.className = "compare-checkbox"; + mCheckbox.style.display = window.compareModeEnabled ? "inline-block" : "none"; mCheckbox.dataset.username = user.id; if (window.selectedCompareUsers && window.selectedCompareUsers.some(u => u.id === user.id)) { mCheckbox.checked = true; diff --git a/frontend/leaderboard.html b/frontend/leaderboard.html index e1657ed8..6339222f 100644 --- a/frontend/leaderboard.html +++ b/frontend/leaderboard.html @@ -99,6 +99,9 @@

Leaderboard

ESC +
@@ -348,6 +351,11 @@

Leaderboard

return; } + // Clear comparison selections when switching tabs + if (window.clearCompareSelection) { + window.clearCompareSelection(); + } + activeDatasetType = activeTab; currentPage = 1; applyFiltersAndRender(); @@ -359,7 +367,10 @@

Leaderboard

$ compare_peers --selected=0/3
- +
+ + +
@@ -386,7 +397,7 @@

Leaderboard

-

> metrics_summary

+

> metrics_summary

@@ -403,9 +414,10 @@

> difficulty_breakdown

-

> progress_history

+

> progress_history

+
diff --git a/frontend/styles/main.css b/frontend/styles/main.css index 1bd3d4ad..a3c021f4 100644 --- a/frontend/styles/main.css +++ b/frontend/styles/main.css @@ -2532,6 +2532,11 @@ body::-webkit-scrollbar-thumb { background: var(--bg); vertical-align: middle; flex-shrink: 0; + display: none !important; +} + +.compare-mode-active .compare-checkbox { + display: inline-block !important; } .mobile-name { @@ -2789,3 +2794,112 @@ body::-webkit-scrollbar-thumb { height: 250px; width: 100%; } + +/* Search bar compare toggle alignment */ +.search-container { + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 2rem; + width: 100%; +} + +.search-bar { + flex: 1; + margin-bottom: 0 !important; +} + +.compare-toggle-btn { + min-width: auto; + width: auto; + padding: 0.55rem 1.4rem; + font-size: 0.82rem; + white-space: nowrap; + margin: 0; + display: inline-block; +} + +@media (max-width: 768px) { + .search-container { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + .compare-toggle-btn { + width: 100%; + text-align: center; + } +} + +/* Daily Comparison Live Stats Styles */ +#daily-comparison-placeholder { + display: flex; + flex-direction: column; + gap: 15px; + color: var(--text-dim); + font-family: "Fira Code", monospace; + padding: 10px; + height: 100%; + justify-content: center; +} + +#daily-comparison-placeholder.hidden { + display: none !important; +} + +.daily-placeholder-text { + color: var(--orange); + font-size: 0.85rem; + text-align: center; + margin-bottom: 15px; + text-shadow: 0 0 5px rgba(255, 176, 0, 0.15); +} + +.live-stats-cards { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; +} + +.live-stats-card { + background: rgba(0, 255, 65, 0.02); + border: 1px solid var(--border); + border-radius: 4px; + padding: 12px; + min-width: 150px; + flex: 1; + max-width: 220px; + box-shadow: 0 0 8px rgba(0, 255, 65, 0.02); + transition: border-color 0.2s ease; +} + +.live-stats-card:hover { + border-color: var(--green-dim); + box-shadow: 0 0 12px rgba(0, 255, 65, 0.05); +} + +.live-stats-card h4 { + color: var(--green); + margin: 0 0 10px 0; + border-bottom: 1px dashed var(--border); + padding-bottom: 6px; + font-size: 0.85rem; + text-shadow: 0 0 5px rgba(0, 255, 65, 0.2); +} + +.live-stat-row { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + margin-bottom: 5px; +} + +.live-stat-row:last-child { + margin-bottom: 0; + margin-top: 5px; + padding-top: 5px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-weight: 700; + color: var(--text); +} diff --git a/scripts/fetch-student-info.js b/scripts/fetch-student-info.js index 4eee1465..b91f8717 100644 --- a/scripts/fetch-student-info.js +++ b/scripts/fetch-student-info.js @@ -1,3 +1,5 @@ +const axios = require("axios"); + function getFileName(daysAgo) { const now = new Date(); now.setDate(now.getDate() - daysAgo); @@ -16,6 +18,7 @@ async function fetchStudentHistory(username) { let history = []; let missingFilesCount = 0; + let foundAny = false; const maxDays = 365; const chunkSize = 100; @@ -31,13 +34,9 @@ async function fetchStudentHistory(username) { const fileName = getFileName(daysAgo); const rawUrl = `https://raw.githubusercontent.com/codepvg/leetcode-ranking-data/main/daily/${fileName}`; - const p = fetch(rawUrl) - .then(async (res) => { - if (!res.ok) { - return { daysAgo, fileName, ok: false }; - } - const data = await res.json(); - return { daysAgo, fileName, ok: true, data }; + const p = axios.get(rawUrl) + .then((res) => { + return { daysAgo, fileName, ok: true, data: res.data }; }) .catch((err) => { return { daysAgo, fileName, ok: false, error: err }; @@ -50,15 +49,18 @@ async function fetchStudentHistory(username) { for (const result of results) { if (!result.ok) { - missingFilesCount++; - if (missingFilesCount >= 7) { - done = true; - break; + if (foundAny) { + missingFilesCount++; + if (missingFilesCount >= 10) { + done = true; + break; + } } continue; } missingFilesCount = 0; + foundAny = true; const user = result.data.find((u) => u.id === username); @@ -80,9 +82,20 @@ async function fetchStudentHistory(username) { history.sort((a, b) => new Date(a.date) - new Date(b.date)); + let globalRank = null; + try { + const leetcodeRes = await axios.get(`https://leetcode-api-dun.vercel.app/${username}`); + if (leetcodeRes.status === 200 && leetcodeRes.data) { + globalRank = leetcodeRes.data.ranking || null; + } + } catch (e) { + console.error(`Failed to fetch global rank for: ${username}`, e.message); + } + return { username, history, + globalRank, }; }