Comparing you to your Top 5 friends.
// ==UserScript== // @name TUF Pro - Analytics // @namespace https://sharadcodes.github.io // @author sharadcodes // @description Comparing you to your Top 5 friends. // @match *://*.takeuforward.org/* // @supportURL https://github.com/sharadcodes/UserScripts/issues // @version 1.2 // @license MIT // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js // @grant none // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'tuf_friends_list'; let currentUserData = null; let allFriendsData = []; let tabChartInstance = null; // track tab chart separately for proper cleanup // FIX: Plain RGB strings — Chart.js cannot resolve CSS variable references const COLORS = { me: 'rgb(99, 102, 241)', // indigo palette: [ 'rgb(59, 130, 246)', // blue 'rgb(139, 92, 246)', // purple 'rgb(16, 185, 129)', // emerald 'rgb(236, 72, 153)', // pink 'rgb(251, 146, 60)', // amber ] }; const TOP3_COLORS = ['rgb(239, 68, 68)', 'rgb(34, 197, 94)', 'rgb(59, 130, 246)']; function colorFor(user, myId, idx) { if (idx < 3) return TOP3_COLORS[idx]; return user.id === myId ? COLORS.me : COLORS.palette[(idx - 3) % COLORS.palette.length]; } function colorAlpha(rgb, a) { // FIX: build rgba properly — no string-replacing CSS vars return rgb.replace('rgb(', 'rgba(').replace(')', `, ${a})`); } // --- Data Management --- const getFriends = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); const saveFriend = (id) => { const f = getFriends(); if (!f.includes(id.trim())) { localStorage.setItem(STORAGE_KEY, JSON.stringify([...f, id.trim()])); return true; } return false; }; const removeFriend = (id) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(getFriends().filter(x => x !== id))); }; async function fetchStats(username) { const year = new Date().getFullYear(); try { const res = await fetch(`https://backend-go.takeuforward.org/api/v1/streak/heatmap/${username}?year=${year}`); if (!res.ok) throw new Error('HTTP ' + res.status); const json = await res.json(); // FIX: guard against unexpected API shape const months = json?.data?.months || {}; let practice = 0, theory = 0; const dailyDataMap = {}; Object.keys(months).forEach(mStr => { const mIdx = parseInt(mStr) - 1; const monthData = months[mStr]; Object.keys(monthData).forEach(dayStr => { const dayVal = monthData[dayStr]; if (typeof dayVal !== 'object' || dayVal === null) return; const p = dayVal.dsa_practice_problem || 0; const t = dayVal.dsa_theory || 0; practice += p; theory += t; const date = new Date(year, mIdx, parseInt(dayStr)); dailyDataMap[date.toISOString().split('T')[0]] = { practice: p, theory: t }; }); }); const today = new Date(); // FIX: Array(30).fill(obj) shares one object reference — use Array.from instead const last30Days = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(d.getDate() - (29 - i)); const key = d.toISOString().split('T')[0]; const entry = dailyDataMap[key] || { practice: 0, theory: 0 }; return { date: key, practice: entry.practice, theory: entry.theory, total: entry.practice + entry.theory }; }); return { id: username, total: json?.data?.total || 0, practice, theory, last30Total: last30Days.reduce((s, d) => s + d.total, 0), last30Days, error: false, }; } catch { return { id: username, total: 0, practice: 0, theory: 0, last30Total: 0, // FIX: use Array.from so each day object is independent last30Days: Array.from({ length: 30 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() - (29 - i)); return { date: d.toISOString().split('T')[0], practice: 0, theory: 0, total: 0 }; }), error: true, }; } } // --- Modal --- function createModal(buildFn) { const overlay = document.createElement('div'); overlay.id = 'tuf-modal-overlay'; Object.assign(overlay.style, { position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.75)', zIndex: '9999', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(6px)', }); const modal = document.createElement('div'); Object.assign(modal.style, { background: 'var(--color-bg, #fff)', color: 'var(--profile-text-primary)', padding: '28px 28px 24px', borderRadius: '16px', width: '95%', maxWidth: '960px', maxHeight: '90vh', overflowY: 'auto', border: '1px solid rgba(0,0,0,0.1)', position: 'relative', boxShadow: '0 20px 60px rgba(0,0,0,0.3)', }); // FIX: clicking backdrop closes modal overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); const closeBtn = document.createElement('button'); closeBtn.textContent = '×'; Object.assign(closeBtn.style, { position: 'absolute', top: '12px', right: '16px', fontSize: '26px', lineHeight: '1', background: 'none', border: 'none', cursor: 'pointer', color: '#999', padding: '4px 8px', borderRadius: '6px', }); closeBtn.addEventListener('click', () => overlay.remove()); buildFn(modal); modal.appendChild(closeBtn); overlay.appendChild(modal); document.body.appendChild(overlay); } // --- Shared Chart defaults --- function baseTooltip() { return { backgroundColor: 'rgba(15,15,15,0.85)', padding: 10, titleFont: { size: 12, weight: 'bold' }, bodyFont: { size: 11 }, borderColor: 'rgba(255,255,255,0.15)', borderWidth: 1, displayColors: true, }; } function baseTick(size = 9) { return { font: { size }, color: '#888' }; } // Day labels: show all dates function sparseLabels(days) { return days.map(d => { const dt = new Date(d.date); return `${dt.getMonth() + 1}/${dt.getDate()}`; }); } // --- Analytics Tab (Quick Chart) --- function renderChartTab(container, myId) { // FIX: if Analytics opened before Leaderboard, auto-fetch data if (!currentUserData) { container.innerHTML = '<div style="padding:16px;text-align:center;font-size:12px;color:#888;">Loading…</div>'; const friends = getFriends(); Promise.all([...new Set([myId, ...friends])].map(fetchStats)).then(data => { currentUserData = data.find(u => u.id === myId); allFriendsData = data.filter(u => u.id !== myId && !u.error); renderChartTab(container, myId); }); return; } // FIX: destroy previous tab chart instance before rebuilding if (tabChartInstance) { tabChartInstance.destroy(); tabChartInstance = null; } // FIX: sort by last-30-days total, not annual total const chartData = [currentUserData, ...allFriendsData.slice(0, 5)] .sort((a, b) => b.last30Total - a.last30Total); container.innerHTML = ` <div style="padding:8px;height:100%;display:flex;flex-direction:column;gap:8px;"> <div style="display:flex;justify-content:space-between;align-items:center;"> <span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#888;">Last 30 Days</span> <button id="view-detailed" style="font-size:10px;font-weight:700;color:#6366F1;background:none;border:none;cursor:pointer;padding:0;text-decoration:none;">Deep Dive ↗</button> </div> <div style="flex:1;position:relative;min-height:0;"> <canvas id="tuf-quick-chart" style="width:100%;height:100%;display:block;"></canvas> </div> </div> `; // FIX: requestAnimationFrame ensures the canvas is laid out before Chart.js reads its size requestAnimationFrame(() => { const ctx = document.getElementById('tuf-quick-chart'); if (!ctx) return; tabChartInstance = new Chart(ctx, { type: 'line', data: { labels: sparseLabels(chartData[0].last30Days), datasets: chartData.map((user, idx) => { const c = colorFor(user, myId, idx); return { label: user.id === myId ? `${user.id} (You)` : user.id, data: user.last30Days.map(d => d.total), borderColor: c, backgroundColor: colorAlpha(c, 0.07), borderWidth: 2, pointRadius: 0, pointHoverRadius: 5, fill: true, tension: 0.35, }; }), }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, animation: { duration: 400 }, plugins: { legend: { position: 'bottom', labels: { font: { size: 9, weight: 'bold' }, padding: 8, usePointStyle: true, pointStyle: 'circle' }, }, tooltip: baseTooltip(), }, scales: { y: { beginAtZero: true, ticks: baseTick(8), grid: { color: 'rgba(0,0,0,0.04)' } }, // FIX: maxRotation: 0 keeps labels horizontal so they don't overlap x: { ticks: { ...baseTick(8), maxRotation: 0 }, grid: { display: false } }, }, }, }); }); document.getElementById('view-detailed').onclick = () => renderModalChart(chartData, myId); } // --- Deep Dive Modal --- function renderModalChart(chartData, myId) { createModal(modal => { modal.innerHTML = ` <div style="margin-bottom:18px;padding-right:40px;"> <h2 style="font-size:20px;font-weight:800;margin:0 0 2px;">Last 30 Days — Deep Dive</h2> <p style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#888;margin:0;"> You vs top ${chartData.length - 1} friends </p> </div> <div id="tuf-legend" style="display:flex;flex-wrap:wrap;gap:10px;padding:10px 12px; background:rgba(0,0,0,0.03);border-radius:10px;margin-bottom:18px;"></div> <div id="tuf-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:18px;"></div> <div style="display:grid;grid-template-columns:minmax(0,1fr);gap:14px;margin-bottom:14px;"> <div style="background:rgba(0,0,0,0.03);border-radius:12px;padding:14px;"> <p style="font-size:11px;font-weight:700;text-transform:uppercase;color:#888;margin:0 0 8px;">Daily Trend</p> <div style="height:200px;position:relative;"> <canvas id="tuf-trend-chart"></canvas> </div> </div> </div> <div style="background:rgba(0,0,0,0.03);border-radius:12px;padding:14px;"> <p style="font-size:11px;font-weight:700;text-transform:uppercase;color:#888;margin:0 0 8px;">Practice vs Theory — All Users</p> <div id="tuf-progress-table" style="max-height:250px;overflow-y:auto;"></div> </div> `; // Legend with 30-day totals const legend = modal.querySelector('#tuf-legend'); chartData.forEach((u, i) => { const c = colorFor(u, myId, i); const el = document.createElement('div'); el.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:11px;font-weight:700;'; el.style.color = c; el.innerHTML = ` <span style="width:10px;height:10px;border-radius:50%;background:${c};flex-shrink:0;"></span> ${u.id}${u.id === myId ? ' (You)' : ''} <span style="color:#aaa;font-weight:400;">· ${u.last30Total}</span>`; legend.appendChild(el); }); // Key stats const stats = modal.querySelector('#tuf-stats'); chartData.forEach((u, i) => { const c = colorFor(u, myId, i); const practice = u.last30Days.reduce((s, d) => s + d.practice, 0); const theory = u.last30Days.reduce((s, d) => s + d.theory, 0); const ratio = theory > 0 ? (practice / theory).toFixed(2) : practice.toFixed(2); const el = document.createElement('div'); el.style.cssText = 'background:rgba(0,0,0,0.03);border-radius:10px;padding:12px;'; el.innerHTML = ` <div style="font-size:11px;font-weight:700;color:${u.id === myId ? c : '#666'};margin-bottom:4px;"> ${u.id}${u.id === myId ? ' (You)' : ''} </div> <div style="font-size:20px;font-weight:800;color:${c};margin-bottom:2px;">${u.last30Total}</div> <div style="font-size:9px;color:#888;">Practice: ${practice} · Theory: ${theory}</div> <div style="font-size:9px;color:#888;">P/T Ratio: ${ratio}</div> `; stats.appendChild(el); }); // FIX: requestAnimationFrame so canvases are sized before Chart.js reads them requestAnimationFrame(() => { // Chart 1: trend lines const trendCtx = modal.querySelector('#tuf-trend-chart'); if (trendCtx) { new Chart(trendCtx, { type: 'line', data: { labels: sparseLabels(chartData[0].last30Days), datasets: chartData.map((user, idx) => { const c = colorFor(user, myId, idx); return { label: user.id, data: user.last30Days.map(d => d.total), borderColor: c, backgroundColor: colorAlpha(c, 0.06), borderWidth: user.id === myId ? 2.5 : 1.5, pointRadius: 0, pointHoverRadius: 5, fill: user.id === myId, tension: 0.4, }; }), }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: baseTooltip() }, scales: { y: { beginAtZero: true, ticks: baseTick(9), grid: { color: 'rgba(0,0,0,0.04)' } }, x: { ticks: { ...baseTick(8), maxRotation: 0 }, grid: { display: false } }, }, }, }); } // Progress bar table for practice vs theory const progressTable = modal.querySelector('#tuf-progress-table'); chartData.forEach((u, i) => { const c = colorFor(u, myId, i); const practice = u.last30Days.reduce((s, d) => s + d.practice, 0); const theory = u.last30Days.reduce((s, d) => s + d.theory, 0); const total = practice + theory; const practicePct = total > 0 ? ((practice / total) * 100).toFixed(1) : 0; const theoryPct = total > 0 ? ((theory / total) * 100).toFixed(1) : 0; const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,0.05);'; row.innerHTML = ` <div style="font-size:11px;font-weight:700;color:${u.id === myId ? c : '#666'};min-width:80px;flex-shrink:0;"> ${u.id}${u.id === myId ? ' (You)' : ''} </div> <div style="flex:1;height:8px;background:rgba(0,0,0,0.1);border-radius:4px;overflow:hidden;display:flex;"> <div style="width:${practicePct}%;background:rgb(99, 102, 241);"></div> <div style="width:${theoryPct}%;background:rgb(251, 146, 60);"></div> </div> <div style="font-size:10px;color:#888;min-width:80px;text-align:right;flex-shrink:0;"> P:${practice} T:${theory} </div> `; progressTable.appendChild(row); }); }); }); } // --- Leaderboard Tab --- async function renderRanksTab(content, myId) { content.innerHTML = '<div style="padding:16px;text-align:center;font-size:12px;color:#888;">Loading…</div>'; const friends = getFriends(); const data = await Promise.all([...new Set([myId, ...friends])].map(fetchStats)); currentUserData = data.find(u => u.id === myId); allFriendsData = data.filter(u => u.id !== myId && !u.error); data.sort((a, b) => b.total - a.total); const top3 = data.slice(0, 3); const rest = data.slice(3); let html = ` <div style="display:flex;gap:6px;margin-bottom:8px;padding:0 2px;"> <input id="tuf-add-input" type="text" placeholder="Add friend by ID…" style="flex:1;background:transparent;border:1px solid rgba(0,0,0,0.15);border-radius:6px; padding:5px 8px;font-size:11px;outline:none;color:inherit;"> <button id="tuf-add-btn" style="background:#6366F1;color:#fff;border:none;border-radius:6px; padding:5px 12px;font-size:11px;font-weight:700;cursor:pointer;flex-shrink:0;">Add</button> </div> `; // FIX: empty state when no friends if (data.length <= 1 && friends.length === 0) { html += `<div style="padding:28px 8px;text-align:center;color:#aaa;font-size:12px;line-height:1.7;"> No friends yet.<br>Add a friend ID above to compare scores. </div>`; } else { // Podium if (top3.length > 0) { const podium = []; if (top3[1]) podium.push({ ...top3[1], r: 2, h: 52, medal: '#9CA3AF' }); if (top3[0]) podium.push({ ...top3[0], r: 1, h: 76, medal: '#EAB308' }); if (top3[2]) podium.push({ ...top3[2], r: 3, h: 36, medal: '#F97316' }); html += `<div style="display:flex;justify-content:center;align-items:flex-end;gap:6px; border-bottom:1px solid rgba(0,0,0,0.08);padding:6px 4px 8px;margin-bottom:6px;">`; podium.forEach(x => { const isMe = x.id === myId; // FIX: full username via title tooltip, CSS ellipsis for overflow html += ` <div style="display:flex;flex-direction:column;align-items:center;flex:1;position:relative;"> ${!isMe ? `<button class="tuf-del" data-id="${x.id}" title="Remove" style="position:absolute;top:-2px;right:0;font-size:14px;line-height:1; background:rgba(239,68,68,.1);border:none;cursor:pointer; color:#EF4444;padding:2px 5px;border-radius:4px;">×</button>` : ''} <div style="font-size:9px;font-weight:700;max-width:60px;overflow:hidden; text-overflow:ellipsis;white-space:nowrap;text-align:center; color:${isMe ? '#6366F1' : 'inherit'};" title="${x.id}">${x.id}</div> <div style="font-size:11px;font-weight:800;color:${x.medal};">${x.total}</div> <div style="width:100%;height:${x.h}px;border-radius:4px 4px 0 0; background:${isMe ? 'rgba(99,102,241,.1)' : 'rgba(0,0,0,.05)'}; border-top:2px solid ${isMe ? '#6366F1' : x.medal}; display:flex;align-items:flex-start;justify-content:center;padding-top:4px;"> <span style="font-size:9px;font-weight:800;opacity:.3;">${x.r}</span> </div> </div>`; }); html += `</div>`; } // Remaining list if (rest.length > 0) { html += `<div style="overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:2px;">`; rest.forEach((u, i) => { const isMe = u.id === myId; html += ` <div style="display:flex;justify-content:space-between;align-items:center; padding:5px 8px;border-radius:7px; background:${isMe ? 'rgba(99,102,241,.08)' : 'transparent'}; border-left:${isMe ? '2px solid #6366F1' : '2px solid transparent'};"> <span style="font-size:11px;font-weight:700; color:${isMe ? '#6366F1' : 'inherit'}; white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:130px;" title="${u.id}">${i + 4}. ${u.id}</span> <div style="display:flex;align-items:center;gap:8px;flex-shrink:0;"> <span style="font-size:11px;font-weight:800;color:#6366F1;">${u.total}</span> ${!isMe ? `<button class="tuf-del" data-id="${u.id}" title="Remove" style="font-size:13px;background:rgba(239,68,68,.1);border:none; cursor:pointer;color:#EF4444;padding:1px 5px;border-radius:4px; line-height:1.4;">×</button>` : ''} </div> </div>`; }); html += `</div>`; } } content.innerHTML = `<div style="display:flex;flex-direction:column;height:100%;gap:0;overflow:hidden;">${html}</div>`; // FIX: Enter key triggers add const input = content.querySelector('#tuf-add-input'); const addFn = () => { const v = input.value.trim(); if (v && saveFriend(v)) { input.value = ''; renderRanksTab(content, myId); } }; content.querySelector('#tuf-add-btn').addEventListener('click', addFn); input.addEventListener('keydown', e => { if (e.key === 'Enter') addFn(); }); // FIX: delete buttons always visible (not opacity-0) content.querySelectorAll('.tuf-del').forEach(b => b.addEventListener('click', e => { removeFriend(e.currentTarget.dataset.id); renderRanksTab(content, myId); }) ); } // --- Bootstrap --- function init(target) { const myId = window.location.pathname.split('/')[2]; target.innerHTML = ` <div id="tuf-box" style="display:flex;flex-direction:column;height:100%;width:100%;"> <div style="display:flex;border-bottom:1px solid rgba(0,0,0,0.08);margin-bottom:6px;"> <button id="t-lb" style="flex:1;padding:7px 0;font-size:10px;font-weight:700;text-transform:uppercase; letter-spacing:.05em;background:none;border:none;cursor:pointer; color:#6366F1;border-bottom:2px solid #6366F1;">Leaderboard</button> <button id="t-ac" style="flex:1;padding:7px 0;font-size:10px;font-weight:700;text-transform:uppercase; letter-spacing:.05em;background:none;border:none;cursor:pointer; color:#aaa;border-bottom:2px solid transparent;">Analytics</button> </div> <div id="tuf-content" style="flex:1;display:flex;flex-direction:column; min-height:240px;max-height:340px;overflow:hidden;"></div> </div> `; const content = target.querySelector('#tuf-content'); const btnLb = target.querySelector('#t-lb'); const btnAc = target.querySelector('#t-ac'); const activateTab = (which) => { const isLb = which === 'lb'; btnLb.style.color = isLb ? '#6366F1' : '#aaa'; btnLb.style.borderBottom = isLb ? '2px solid #6366F1' : '2px solid transparent'; btnAc.style.color = isLb ? '#aaa' : '#6366F1'; btnAc.style.borderBottom = isLb ? '2px solid transparent' : '2px solid #6366F1'; if (isLb) renderRanksTab(content, myId); else renderChartTab(content, myId); }; btnLb.addEventListener('click', () => activateTab('lb')); btnAc.addEventListener('click', () => activateTab('ac')); activateTab('lb'); } new MutationObserver(() => { const cell = document.querySelector('[style*="grid-area: skills"] .profile-skills-container'); if (cell && !document.getElementById('tuf-box')) { cell.innerHTML = ''; init(cell); } }).observe(document.body, { childList: true, subtree: true }); })();