diff --git a/src/data.js b/src/data.js index a9943c4..243dbfc 100644 --- a/src/data.js +++ b/src/data.js @@ -2214,10 +2214,102 @@ function getActiveSessions() { return active; } +// ── Leaderboard stats ───────────────────────────────────── + +const ANON_NAMES_ADJ = ['brave','swift','calm','bold','keen','wise','cool','fast','wild','epic','rare','pure','warm','dark','deep','fair','free','glad','gold','iron']; +const ANON_NAMES_NOUN = ['fox','owl','cat','wolf','bear','hawk','lion','deer','hare','crow','lynx','moth','seal','wren','dove','frog','newt','crab','swan','kite']; + +function getOrCreateAnonId() { + const configDir = path.join(os.homedir(), '.codedash'); + const idFile = path.join(configDir, 'anon-id.json'); + try { + const data = JSON.parse(fs.readFileSync(idFile, 'utf8')); + if (data.id && data.name) return data; + } catch {} + // Generate new + const id = require('crypto').randomUUID(); + const adj = ANON_NAMES_ADJ[Math.floor(Math.random() * ANON_NAMES_ADJ.length)]; + const noun = ANON_NAMES_NOUN[Math.floor(Math.random() * ANON_NAMES_NOUN.length)]; + const num = Math.floor(Math.random() * 100); + const name = adj + '-' + noun + '-' + num; + const data = { id, name, createdAt: new Date().toISOString() }; + if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(idFile, JSON.stringify(data, null, 2)); + return data; +} + +function getDailyStats(sessions) { + const byDay = {}; + for (const s of sessions) { + if (!s.date) continue; + if (!byDay[s.date]) byDay[s.date] = { date: s.date, sessions: 0, messages: 0, hours: 0, cost: 0, agents: {} }; + const d = byDay[s.date]; + d.sessions++; + d.messages += (s.detail_messages || s.messages || 0); + // Hours = time between first and last message + const durationMs = (s.last_ts || 0) - (s.first_ts || 0); + if (durationMs > 0) d.hours += durationMs / 3600000; + // Cost + const costData = computeSessionCost(s.id, s.project); + if (costData && costData.cost) d.cost += costData.cost; + // Agent breakdown + const tool = s.tool || 'unknown'; + if (!d.agents[tool]) d.agents[tool] = 0; + d.agents[tool]++; + } + return Object.values(byDay).sort((a, b) => b.date.localeCompare(a.date)); +} + +function getLeaderboardStats() { + const sessions = loadSessions(); + const anon = getOrCreateAnonId(); + const daily = getDailyStats(sessions); + + // Totals + let totalMessages = 0, totalHours = 0, totalCost = 0, totalSessions = sessions.length; + const agentTotals = {}; + for (const d of daily) { + totalMessages += d.messages; + totalHours += d.hours; + totalCost += d.cost; + for (const [agent, count] of Object.entries(d.agents)) { + agentTotals[agent] = (agentTotals[agent] || 0) + count; + } + } + + // Today + const today = new Date().toISOString().slice(0, 10); + const todayStats = daily.find(d => d.date === today) || { sessions: 0, messages: 0, hours: 0, cost: 0, agents: {} }; + + // Streak (consecutive days with sessions) + let streak = 0; + const dt = new Date(); + for (let i = 0; i < 365; i++) { + const day = dt.toISOString().slice(0, 10); + if (daily.find(d => d.date === day)) { + streak++; + dt.setDate(dt.getDate() - 1); + } else { + break; + } + } + + return { + anon, + today: todayStats, + totals: { sessions: totalSessions, messages: totalMessages, hours: Math.round(totalHours * 10) / 10, cost: Math.round(totalCost * 100) / 100 }, + agents: agentTotals, + streak, + daily: daily.slice(0, 30), // last 30 days + activeDays: daily.length, + }; +} + module.exports = { loadSessions, loadSessionDetail, getProjectGitInfo, + getLeaderboardStats, deleteSession, getGitCommits, exportSessionMarkdown, diff --git a/src/frontend/app.js b/src/frontend/app.js index 5604a97..aa624ae 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1124,6 +1124,11 @@ function render() { return; } + if (currentView === 'leaderboard') { + renderLeaderboard(content); + return; + } + if (currentView === 'settings') { renderSettings(content); return; @@ -2633,6 +2638,78 @@ function renderSettings(container) { loadLLMSettings(); } +async function renderLeaderboard(container) { + container.innerHTML = '
Loading stats...
'; + try { + var resp = await fetch('/api/leaderboard'); + var data = await resp.json(); + + var html = '
'; + + // Header card — your identity + html += '
'; + html += '
' + escHtml(data.anon.name.split('-').map(function(w){return w[0].toUpperCase()}).join('')) + '
'; + html += '
'; + html += '
' + escHtml(data.anon.name) + '
'; + html += '
' + data.streak + ' day streak
'; + html += '
'; + + // Today stats + html += '
Today
'; + html += '
'; + html += '
' + data.today.messages + '
messages
'; + html += '
' + data.today.hours.toFixed(1) + 'h
agent time
'; + html += '
' + data.today.sessions + '
sessions
'; + html += '
$' + data.today.cost.toFixed(2) + '
cost
'; + html += '
'; + + // All time + html += '
All Time
'; + html += '
'; + html += '
' + data.totals.messages.toLocaleString() + '
messages
'; + html += '
' + data.totals.hours.toFixed(0) + 'h
agent time
'; + html += '
' + data.totals.sessions + '
sessions
'; + html += '
$' + data.totals.cost.toFixed(2) + '
cost
'; + html += '
'; + + // Agents breakdown + html += '
Agents
'; + html += '
'; + var agentEntries = Object.entries(data.agents).sort(function(a,b){return b[1]-a[1]}); + agentEntries.forEach(function(e) { + var pct = data.totals.sessions > 0 ? Math.round(e[1] / data.totals.sessions * 100) : 0; + html += '
'; + html += '' + escHtml(e[0]) + ''; + html += '
'; + html += '' + e[1] + ' (' + pct + '%)'; + html += '
'; + }); + html += '
'; + + // Daily chart (last 14 days) + html += '
Last 14 Days
'; + html += '
'; + var last14 = data.daily.slice(0, 14).reverse(); + var maxMsg = Math.max.apply(null, last14.map(function(d){return d.messages})) || 1; + last14.forEach(function(d) { + var h = Math.max(4, Math.round(d.messages / maxMsg * 120)); + var dayLabel = d.date.slice(5); // MM-DD + html += '
'; + html += '
'; + html += '
' + dayLabel + '
'; + html += '
'; + }); + html += '
'; + + html += ''; + html += '
'; + + container.innerHTML = html; + } catch (e) { + container.innerHTML = '
Failed to load stats: ' + escHtml(e.message) + '
'; + } +} + async function renderChangelog(container) { container.innerHTML = '
Loading changelog...
'; try { diff --git a/src/frontend/index.html b/src/frontend/index.html index dfe59ba..fdb9bff 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -42,6 +42,10 @@ Starred +