diff --git a/data/badges.json b/data/badges.json new file mode 100644 index 00000000..1b667a17 --- /dev/null +++ b/data/badges.json @@ -0,0 +1,9 @@ +{ + "Nick_kaushik": [ + "HOT_STREAK" + ], + "PrimeLogic": [ + "SPEEDRUN", + "UP_LINK" + ] +} diff --git a/frontend/js/leaderboard/compare.js b/frontend/js/leaderboard/compare.js new file mode 100644 index 00000000..3f4f9dbe --- /dev/null +++ b/frontend/js/leaderboard/compare.js @@ -0,0 +1,658 @@ +// Peer Comparison Module + +window.selectedCompareUsers = []; +window.compareModeEnabled = false; +let difficultyChartInstance = null; +let historyChartInstance = null; + +// Initialize on load +document.addEventListener("DOMContentLoaded", () => { + setupCompareListeners(); +}); + +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); + } + + 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(); + } + }); +} + +// 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) { + 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; + } + + // Ensure display state matches comparison mode + cb.style.display = window.compareModeEnabled ? "inline-block" : "none"; + }); +} + +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 { + // 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 { + 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 student details for ${user.id}`, e); + return { + user, + history: [], + globalRank: null, + liveData: null, + 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: "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 }, + { 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 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(); + } + + // 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 + 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]; + + // 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) { + 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, + }, + }, + }, + }, + }, + }); +} + +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 a99a8fc9..c3ba6306 100644 --- a/frontend/js/leaderboard/render.js +++ b/frontend/js/leaderboard/render.js @@ -85,6 +85,25 @@ 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.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; + } + 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 +222,25 @@ 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.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; + } + 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..6339222f 100644 --- a/frontend/leaderboard.html +++ b/frontend/leaderboard.html @@ -14,6 +14,8 @@ + + @@ -97,6 +99,9 @@

Leaderboard

ESC +
@@ -346,13 +351,83 @@

Leaderboard

return; } + // Clear comparison selections when switching tabs + if (window.clearCompareSelection) { + window.clearCompareSelection(); + } + activeDatasetType = activeTab; currentPage = 1; applyFiltersAndRender(); } - - - - - + + + + + + + + + + + + diff --git a/frontend/styles/main.css b/frontend/styles/main.css index 1bed409b..a3c021f4 100644 --- a/frontend/styles/main.css +++ b/frontend/styles/main.css @@ -2517,3 +2517,389 @@ 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; + display: none !important; +} + +.compare-mode-active .compare-checkbox { + display: inline-block !important; +} + +.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%; +} + +/* 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..93a2d79e 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,40 @@ 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); + } + + let badges = []; + try { + const fs = require("fs"); + const path = require("path"); + const localBadgesPath = path.join(__dirname, "..", "data", "badges.json"); + if (fs.existsSync(localBadgesPath)) { + const rawBadges = fs.readFileSync(localBadgesPath, "utf8"); + const localBadges = JSON.parse(rawBadges); + badges = localBadges[username] || []; + } else { + const badgesRes = await axios.get("https://raw.githubusercontent.com/codepvg/leetcode-ranking-data/main/badges.json"); + if (badgesRes.status === 200 && badgesRes.data) { + badges = badgesRes.data[username] || []; + } + } + } catch (e) { + console.warn(`Failed to fetch badges for: ${username}`, e.message); + } + return { username, history, + globalRank, + badges, }; } diff --git a/scripts/sync-leaderboard.js b/scripts/sync-leaderboard.js index c50ec86f..24611c32 100644 --- a/scripts/sync-leaderboard.js +++ b/scripts/sync-leaderboard.js @@ -9,6 +9,7 @@ async function fetchData(url) { easySolved: res.data.easySolved || 0, mediumSolved: res.data.mediumSolved || 0, hardSolved: res.data.hardSolved || 0, + submissionCalendar: res.data.submissionCalendar || {}, }; } catch (err) { console.error("API failed to respond: ", err.message); @@ -95,6 +96,39 @@ async function computeRankChanges(currentSorted, filename) { }); } +function getRankDelta(rankChange) { + if (!rankChange || rankChange === "NEW" || rankChange === "=") return 0; + if (rankChange.startsWith("+")) { + return parseInt(rankChange.slice(1), 10) || 0; + } + return 0; +} + +function hasHotStreak(submissionCalendar) { + if (!submissionCalendar) return false; + + const now = new Date(); + const todayUTC = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const oneDaySeconds = 86400; + const todayTimestamp = Math.floor(todayUTC / 1000); + + const checkStreak = (endTimestamp) => { + for (let i = 0; i < 7; i++) { + const ts = endTimestamp - i * oneDaySeconds; + if (!submissionCalendar[ts] && !submissionCalendar[String(ts)]) { + return false; + } + const val = submissionCalendar[ts] !== undefined ? submissionCalendar[ts] : submissionCalendar[String(ts)]; + if (val <= 0) { + return false; + } + } + return true; + }; + + return checkStreak(todayTimestamp) || checkStreak(todayTimestamp - oneDaySeconds); +} + (async () => { const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "..", "data"); console.log(`Using data directory: ${DATA_DIR}`); @@ -151,20 +185,9 @@ async function computeRankChanges(currentSorted, filename) { }); console.log("Sorting collected data..."); overallData.sort((a, b) => b.score - a.score); - console.log("Writing sorted daily data to overall file..."); + const overallFilepath = path.join(DATA_DIR, "overall.json"); await computeRankChanges(overallData, "overall.json"); - try { - fs.writeFileSync( - overallFilepath, - JSON.stringify(overallData, null, 2), - "utf8", - ); - console.log("Daily data saved successfully"); - } catch (err) { - console.error(`Failed to write json file: `, err.message); - process.exit(1); - } dailyData = JSON.parse(JSON.stringify(overallData)); console.log(" "); @@ -209,16 +232,8 @@ async function computeRankChanges(currentSorted, filename) { console.log("Sorting calculated data..."); dailyData.sort((a, b) => b.score - a.score); - console.log("Writing sorted daily data to daily.json..."); const dailyFilepath = path.join(DATA_DIR, "daily.json"); await computeRankChanges(dailyData, "daily.json"); - try { - fs.writeFileSync(dailyFilepath, JSON.stringify(dailyData, null, 2), "utf8"); - console.log("Daily data saved successfully"); - } catch (err) { - console.error(`Failed to write json file: `, err.message); - process.exit(1); - } weeklyData = JSON.parse(JSON.stringify(overallData)); console.log(" "); @@ -261,24 +276,11 @@ async function computeRankChanges(currentSorted, filename) { } console.log("Calculation done"); console.log(""); - console.log("Sorting calculated data..."); weeklyData.sort((a, b) => b.score - a.score); - console.log("Writing sorted weekly data to weekly.json..."); const weeklyFilepath = path.join(DATA_DIR, "weekly.json"); await computeRankChanges(weeklyData, "weekly.json"); - try { - fs.writeFileSync( - weeklyFilepath, - JSON.stringify(weeklyData, null, 2), - "utf8", - ); - console.log("Weekly data saved successfully"); - } catch (err) { - console.error(`Failed to write json file: `, err.message); - process.exit(1); - } monthlyData = JSON.parse(JSON.stringify(overallData)); console.log(" "); @@ -325,18 +327,71 @@ async function computeRankChanges(currentSorted, filename) { console.log("Sorting calculated data..."); monthlyData.sort((a, b) => b.score - a.score); - console.log("Writing sorted monthly data to monthly.json..."); const monthlyFilepath = path.join(DATA_DIR, "monthly.json"); await computeRankChanges(monthlyData, "monthly.json"); + + // --- Badge Calculations --- + console.log(" "); + console.log("Calculating dynamic badges..."); + const badgesMap = {}; + + // 1. SPEEDRUN: top 3 weekly progress with score > 0 + const speedrunCandidates = weeklyData + .filter((u) => u.score > 0) + .slice(0, 3) + .map((u) => u.id); + + overallData.forEach((user) => { + const userBadges = []; + + // 2. UP_LINK: overall rank improvement >= 5 + const delta = getRankDelta(user.rankChange); + if (delta >= 5) { + userBadges.push("UP_LINK"); + } + + // SPEEDRUN check + if (speedrunCandidates.includes(user.id)) { + userBadges.push("SPEEDRUN"); + } + + // 3. HOT_STREAK: weekly solved >= 7 and verified daily streak + const weeklyUser = weeklyData.find((w) => w.id === user.id); + const weeklySolved = weeklyUser ? (weeklyUser.data.totalSolved || 0) : 0; + if (weeklySolved >= 7) { + if (hasHotStreak(user.data.submissionCalendar)) { + userBadges.push("HOT_STREAK"); + } + } + + badgesMap[user.id] = userBadges; + }); + + // Apply badges to datasets + const applyBadges = (dataset) => { + dataset.forEach((user) => { + user.badges = badgesMap[user.id] || []; + }); + }; + + applyBadges(overallData); + applyBadges(dailyData); + applyBadges(weeklyData); + applyBadges(monthlyData); + + // Write datasets with badges try { - fs.writeFileSync( - monthlyFilepath, - JSON.stringify(monthlyData, null, 2), - "utf8", - ); - console.log("Monthly data saved successfully"); + fs.writeFileSync(overallFilepath, JSON.stringify(overallData, null, 2), "utf8"); + fs.writeFileSync(dailyFilepath, JSON.stringify(dailyData, null, 2), "utf8"); + fs.writeFileSync(weeklyFilepath, JSON.stringify(weeklyData, null, 2), "utf8"); + fs.writeFileSync(monthlyFilepath, JSON.stringify(monthlyData, null, 2), "utf8"); + + const badgesFilepath = path.join(DATA_DIR, "badges.json"); + fs.writeFileSync(badgesFilepath, JSON.stringify(badgesMap, null, 2), "utf8"); + + console.log("All leaderboard data files and badges.json saved successfully ✅"); } catch (err) { - console.error(`Failed to write json file: `, err.message); + console.error("Failed to write data files at the end of sync: ", err.message); process.exit(1); } 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