Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,11 @@ function render() {
return;
}

if (currentView === 'leaderboard') {
renderLeaderboard(content);
return;
}

if (currentView === 'settings') {
renderSettings(content);
return;
Expand Down Expand Up @@ -2633,6 +2638,78 @@ function renderSettings(container) {
loadLLMSettings();
}

async function renderLeaderboard(container) {
container.innerHTML = '<div class="loading">Loading stats...</div>';
try {
var resp = await fetch('/api/leaderboard');
var data = await resp.json();

var html = '<div class="leaderboard-container">';

// Header card — your identity
html += '<div class="lb-hero">';
html += '<div class="lb-avatar">' + escHtml(data.anon.name.split('-').map(function(w){return w[0].toUpperCase()}).join('')) + '</div>';
html += '<div class="lb-hero-info">';
html += '<div class="lb-name">' + escHtml(data.anon.name) + '</div>';
html += '<div class="lb-streak">' + data.streak + ' day streak</div>';
html += '</div></div>';

// Today stats
html += '<div class="lb-section-title">Today</div>';
html += '<div class="lb-stats-grid">';
html += '<div class="lb-stat"><div class="lb-stat-value">' + data.today.messages + '</div><div class="lb-stat-label">messages</div></div>';
html += '<div class="lb-stat"><div class="lb-stat-value">' + data.today.hours.toFixed(1) + 'h</div><div class="lb-stat-label">agent time</div></div>';
html += '<div class="lb-stat"><div class="lb-stat-value">' + data.today.sessions + '</div><div class="lb-stat-label">sessions</div></div>';
html += '<div class="lb-stat"><div class="lb-stat-value">$' + data.today.cost.toFixed(2) + '</div><div class="lb-stat-label">cost</div></div>';
html += '</div>';

// All time
html += '<div class="lb-section-title">All Time</div>';
html += '<div class="lb-stats-grid">';
html += '<div class="lb-stat"><div class="lb-stat-value">' + data.totals.messages.toLocaleString() + '</div><div class="lb-stat-label">messages</div></div>';
html += '<div class="lb-stat"><div class="lb-stat-value">' + data.totals.hours.toFixed(0) + 'h</div><div class="lb-stat-label">agent time</div></div>';
html += '<div class="lb-stat"><div class="lb-stat-value">' + data.totals.sessions + '</div><div class="lb-stat-label">sessions</div></div>';
html += '<div class="lb-stat"><div class="lb-stat-value">$' + data.totals.cost.toFixed(2) + '</div><div class="lb-stat-label">cost</div></div>';
html += '</div>';

// Agents breakdown
html += '<div class="lb-section-title">Agents</div>';
html += '<div class="lb-agents">';
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 += '<div class="lb-agent-row">';
html += '<span class="tool-badge tool-' + e[0] + '">' + escHtml(e[0]) + '</span>';
html += '<div class="lb-agent-bar"><div class="lb-agent-bar-fill" style="width:' + pct + '%"></div></div>';
html += '<span class="lb-agent-count">' + e[1] + ' (' + pct + '%)</span>';
html += '</div>';
});
html += '</div>';

// Daily chart (last 14 days)
html += '<div class="lb-section-title">Last 14 Days</div>';
html += '<div class="lb-daily-chart">';
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 += '<div class="lb-bar-col">';
html += '<div class="lb-bar" style="height:' + h + 'px" title="' + d.date + ': ' + d.messages + ' msgs, ' + d.hours.toFixed(1) + 'h, $' + d.cost.toFixed(2) + '"></div>';
html += '<div class="lb-bar-label">' + dayLabel + '</div>';
html += '</div>';
});
html += '</div>';

html += '<div class="lb-footer">Active days: ' + data.activeDays + ' | ID: ' + escHtml(data.anon.name) + '</div>';
html += '</div>';

container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="empty-state">Failed to load stats: ' + escHtml(e.message) + '</div>';
}
}

async function renderChangelog(container) {
container.innerHTML = '<div class="loading">Loading changelog...</div>';
try {
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
Starred
</div>
<div class="sidebar-item" data-view="leaderboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9H4.5a2.5 2.5 0 010-5C7 4 7 7 7 7"/><path d="M18 9h1.5a2.5 2.5 0 000-5C17 4 17 7 17 7"/><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M12 4v5"/><path d="M8 16h8"/></svg>
Leaderboard
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-section">Agents</div>
<div class="sidebar-item" data-view="claude-only">
Expand Down
120 changes: 120 additions & 0 deletions src/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,126 @@ body {
padding: 8px 0;
}

/* ── Leaderboard ───────────────────────────────────────────── */

.leaderboard-container { padding: 24px; max-width: 700px; }

.lb-hero {
display: flex;
align-items: center;
gap: 16px;
padding: 24px;
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(168,85,247,0.15));
border-radius: 16px;
margin-bottom: 24px;
border: 1px solid rgba(139,92,246,0.3);
}

.lb-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}

.lb-name { font-size: 20px; font-weight: 700; color: var(--text-primary); }
.lb-streak { font-size: 13px; color: var(--accent-orange); margin-top: 2px; }

.lb-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin: 20px 0 10px;
}

.lb-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}

.lb-stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
text-align: center;
}

.lb-stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.lb-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; }

.lb-agents { display: flex; flex-direction: column; gap: 8px; }

.lb-agent-row {
display: flex;
align-items: center;
gap: 12px;
}
.lb-agent-row .tool-badge { min-width: 80px; text-align: center; }

.lb-agent-bar {
flex: 1;
height: 8px;
background: var(--bg-card);
border-radius: 4px;
overflow: hidden;
}
.lb-agent-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple));
border-radius: 4px;
transition: width 0.5s;
}
.lb-agent-count { font-size: 12px; color: var(--text-muted); min-width: 60px; }

.lb-daily-chart {
display: flex;
align-items: flex-end;
gap: 6px;
height: 150px;
padding: 8px 0;
}

.lb-bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}

.lb-bar {
width: 100%;
max-width: 40px;
background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple));
border-radius: 4px 4px 0 0;
min-height: 4px;
cursor: default;
}
.lb-bar:hover { opacity: 0.8; }

.lb-bar-label { font-size: 10px; color: var(--text-muted); margin-top: 4px; }

.lb-footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
text-align: center;
}

/* ── Changelog ──────────────────────────────────────────────── */

.changelog-container { padding: 20px; max-width: 700px; }
Expand Down
8 changes: 7 additions & 1 deletion src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const http = require('http');
const https = require('https');
const { URL } = require('url');
const { exec } = require('child_process');
const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost, getProjectGitInfo } = require('./data');
const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost, getProjectGitInfo, getLeaderboardStats } = require('./data');
const { detectTerminals, openInTerminal, focusTerminalByPid } = require('./terminals');
const { convertSession } = require('./convert');
const { generateHandoff } = require('./handoff');
Expand Down Expand Up @@ -354,6 +354,12 @@ function startServer(host, port, openBrowser = true) {
}

// ── Changelog ─────────────────────────────
// ── Leaderboard stats ────────────────────
else if (req.method === 'GET' && pathname === '/api/leaderboard') {
const stats = getLeaderboardStats();
json(res, stats);
}

else if (req.method === 'GET' && pathname === '/api/changelog') {
json(res, CHANGELOG);
}
Expand Down
Loading