// Usage rendering module -- heatmap, weekly chart, cumulative chart. // Extracted from render.js with identical logic. import { state } from './state.js'; import { escapeHTML, fmtDuration, fmtTokens, fmtTooltipDate, positionTooltip, formatProjectLabel, $ } from './utils.js'; import registry from './registry.js'; function navigateToSession(sessionId, focusUuid) { if (registry.navigateToSession) { registry.navigateToSession(sessionId, focusUuid); } } export async function renderUsage() { const usage = $('#usage'); if (!usage) return; const data = await window.obelisk.getUsageStats(); const { daily, totalTokens, peakDay, longestTurn } = data; // Build heatmap: 52 weeks x 7 days grid const today = new Date(); const dayMs = 86400000; // Start from the first Sunday on or after 364 days ago (full weeks only) let startDate = new Date(today.getTime() - 364 * dayMs); startDate.setHours(0, 0, 0, 0); const daysUntilSunday = (7 - startDate.getDay()) % 7; startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs); const dailyMap = {}; for (const d of daily) dailyMap[d.day] = d.tokens; const values = daily.map(d => d.tokens).filter(Boolean); const maxTokens = Math.max(...values, 1); // Generate cells (startDate is always a Sunday now) const cells = []; for (let i = 0; i < 371; i++) { const date = new Date(startDate.getTime() + i * dayMs); if (date > today) break; const key = date.toISOString().slice(0, 10); const tokens = dailyMap[key] || 0; const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4)); const col = Math.floor(i / 7); const row = i % 7; cells.push({ key, tokens, level, col, row, date }); } const maxCol = cells.length ? cells[cells.length - 1].col : 0; const cellSize = 11; const cellGap = 2; const step = cellSize + cellGap; const gridWidth = (maxCol + 1) * step + 20; // extra padding for last month label const gridHeight = 7 * step; const cellsHTML = cells.map(c => { const x = c.col * step; const y = c.row * step; return ``; }).join(''); // Month labels const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const monthLabels = []; let lastMonth = -1; for (const c of cells) { const m = c.date.getMonth(); if (m !== lastMonth && c.row === 0) { monthLabels.push({ col: c.col, label: months[m] }); lastMonth = m; } } const monthLabelsHTML = monthLabels.map(m => `${m.label}` ).join(''); // Streak calculation — check gaps between consecutive active days let longestStreak = 0; let streak = 0; const sortedDays = [...daily].filter(d => d.tokens > 0).sort((a, b) => a.day.localeCompare(b.day)); for (let i = 0; i < sortedDays.length; i++) { if (i === 0) { streak = 1; } else { const prev = new Date(sortedDays[i - 1].day).getTime(); const curr = new Date(sortedDays[i].day).getTime(); if (curr - prev === dayMs) { streak++; } else { streak = 1; } } if (streak > longestStreak) longestStreak = streak; } // Current streak: find the most recent active day, then count consecutive days backwards let currentStreak = 0; let startedCounting = false; for (let i = 0; i <= 365; i++) { const d = new Date(today.getTime() - i * dayMs).toISOString().slice(0, 10); if (dailyMap[d] && dailyMap[d] > 0) { startedCounting = true; currentStreak++; } else if (startedCounting) { break; } } usage.innerHTML = `
Token activity
${fmtTokens(totalTokens)} Lifetime tokens
${peakDay ? fmtTokens(peakDay.tokens) : '—'} Peak tokens
${longestTurn ? fmtDuration(longestTurn.turn_duration_ms) : '—'} Longest task
${currentStreak}d Current streak
${longestStreak}d Longest streak
${cellsHTML} ${monthLabelsHTML}
Less More
`; // Heatmap tooltip const heatmapTooltip = document.createElement('div'); heatmapTooltip.className = 'chart-tooltip'; usage.appendChild(heatmapTooltip); usage.querySelectorAll('.heatmap-cell[data-label]').forEach(cell => { cell.addEventListener('mouseenter', () => { heatmapTooltip.textContent = cell.dataset.label; heatmapTooltip.classList.add('show'); }); cell.addEventListener('mousemove', e => { positionTooltip(heatmapTooltip, e.clientX, e.clientY); }); cell.addEventListener('mouseleave', () => heatmapTooltip.classList.remove('show')); cell.addEventListener('click', () => { usage.querySelectorAll('.heatmap-cell.selected').forEach(c => c.classList.remove('selected')); cell.classList.add('selected'); const date = cell.dataset.label.match(/on (.+)$/)?.[1] || ''; const dateKey = cell.getAttribute('data-label').split(' tokens')[0]; // not ideal // Extract ISO date from cells array by matching position const allCells = [...usage.querySelectorAll('.heatmap-cell[data-label]')]; const idx = allCells.indexOf(cell); if (idx >= 0 && idx < cells.length) { showDaySessions(cells[idx].key); } }); }); async function showDaySessions(dateKey) { const panel = usage.querySelector('#day-sessions'); if (!panel) return; const dayStart = dateKey + 'T00:00:00'; const dayEnd = dateKey + 'T23:59:59'; // Find sessions active on this day const daySessions = state.sessions.filter(s => { if (!s.started_at) return false; const end = s.ended_at || s.started_at; return s.started_at <= dayEnd && end >= dayStart; }); // Classify each session const classified = daySessions.map(s => { const isNew = s.started_at.slice(0, 10) === dateKey; let kind = 'continued'; // default: session spans this day if (isNew) { // Check if this project had any session before this one const hasEarlierSession = state.sessions.some( other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at ); kind = hasEarlierSession ? 'new-session' : 'new-workspace'; } return { ...s, kind }; }); if (!classified.length) { panel.innerHTML = `
${fmtTooltipDate(dateKey)} — no sessions
`; return; } // Group by kind for visual hierarchy const newWorkspaces = classified.filter(s => s.kind === 'new-workspace'); const newSessions = classified.filter(s => s.kind === 'new-session'); const continued = classified.filter(s => s.kind === 'continued'); let html = `
${fmtTooltipDate(dateKey)}
`; if (newWorkspaces.length) { html += `
Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}
${newWorkspaces.map(s => ` `).join('')}
`; } if (newSessions.length) { // Group new sessions by project const byProject = {}; for (const s of newSessions) { const p = s.project || '(none)'; if (!byProject[p]) byProject[p] = []; byProject[p].push(s); } html += `
+ Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}
${newSessions.map(s => ` `).join('')}
`; } if (continued.length) { html += `
Continued ${continued.length} session${continued.length > 1 ? 's' : ''}
${continued.map(s => ` `).join('')}
`; } html += `
`; panel.innerHTML = html; panel.querySelectorAll('.activity-item').forEach(row => { row.addEventListener('click', () => navigateToSession(row.dataset.sessionId)); }); } // Tab switching usage.querySelectorAll('.usage-tab').forEach(tab => { tab.addEventListener('click', () => { usage.querySelectorAll('.usage-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); const view = tab.dataset.view; const heatmap = usage.querySelector('.heatmap-container'); const chart = usage.querySelector('#usage-chart'); if (view === 'daily') { heatmap.style.display = ''; chart.style.display = 'none'; } else { heatmap.style.display = 'none'; chart.style.display = ''; if (view === 'weekly') renderWeeklyChart(chart, daily); else renderCumulativeChart(chart, daily); } }); }); // Default: show current month's activity with "show more" for previous months let loadedMonths = 0; showNextMonth(); function showNextMonth() { const panel = usage.querySelector('#day-sessions'); if (!panel) return; const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths, 1); const year = targetDate.getFullYear(); const month = targetDate.getMonth(); loadedMonths++; const monthHTML = buildMonthHTML(year, month); // Remove existing "show more" button const existing = panel.querySelector('.show-more-btn'); if (existing) existing.remove(); panel.insertAdjacentHTML('beforeend', monthHTML); // Add "show more" button const btn = document.createElement('button'); btn.className = 'show-more-btn'; btn.textContent = 'Show more activity'; btn.addEventListener('click', () => showNextMonth()); panel.appendChild(btn); // Wire up session links panel.querySelectorAll('.activity-item:not([data-wired])').forEach(row => { row.setAttribute('data-wired', '1'); row.addEventListener('click', () => navigateToSession(row.dataset.sessionId)); }); } function buildMonthHTML(year, month) { const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`; const nextMonth = month === 11 ? `${year + 1}-01-01` : `${year}-${String(month + 2).padStart(2, '0')}-01`; const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; const monthSessions = state.sessions.filter(s => { if (!s.started_at) return false; const end = s.ended_at || s.started_at; return s.started_at < nextMonth && end >= monthStart; }); const classified = monthSessions.map(s => { const startedInMonth = s.started_at >= monthStart && s.started_at < nextMonth; let kind = 'continued'; if (startedInMonth) { const hasEarlierSession = state.sessions.some( other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at ); kind = hasEarlierSession ? 'new-session' : 'new-workspace'; } return { ...s, kind }; }); const newWorkspaces = classified.filter(s => s.kind === 'new-workspace'); const newSessions = classified.filter(s => s.kind === 'new-session'); const continued = classified.filter(s => s.kind === 'continued'); const headerText = `${monthNames[month]} ${year}`; let html = `
${headerText}
`; if (newWorkspaces.length) { html += `
Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}
${newWorkspaces.map(s => ` `).join('')}
`; } if (newSessions.length) { const byProject = {}; for (const s of newSessions) { const p = s.project || '(none)'; if (!byProject[p]) byProject[p] = []; byProject[p].push(s); } html += `
+ Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}
${newSessions.map(s => ` `).join('')}
`; } if (continued.length) { html += `
Continued ${continued.length} session${continued.length > 1 ? 's' : ''}
${continued.map(s => ` `).join('')}
`; } if (!classified.length) html += `
No sessions this month.
`; html += `
`; return html; } } export function renderWeeklyChart(container, daily) { // Build 52 weekly buckets aligned to the same time range as the heatmap const today = new Date(); const dayMs = 86400000; let startDate = new Date(today.getTime() - 364 * dayMs); startDate.setHours(0, 0, 0, 0); const daysUntilSunday = (7 - startDate.getDay()) % 7; startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs); const dailyMap = {}; for (const d of daily) dailyMap[d.day] = d.tokens; // Aggregate into weeks const weeks = []; for (let w = 0; w < 53; w++) { const weekStart = new Date(startDate.getTime() + w * 7 * dayMs); if (weekStart > today) break; let tokens = 0; for (let d = 0; d < 7; d++) { const date = new Date(weekStart.getTime() + d * dayMs); if (date > today) break; const key = date.toISOString().slice(0, 10); tokens += dailyMap[key] || 0; } weeks.push({ weekStart, tokens }); } if (!weeks.length) { container.innerHTML = '
No data
'; return; } const maxVal = Math.max(...weeks.map(w => w.tokens), 1); const barWidth = 10; const barGap = 3; const chartHeight = 120; const chartWidth = weeks.length * (barWidth + barGap); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; // Month labels const labels = []; let lastMonth = -1; for (let i = 0; i < weeks.length; i++) { const m = weeks[i].weekStart.getMonth(); if (m !== lastMonth) { labels.push({ i, label: months[m] }); lastMonth = m; } } const barsHTML = weeks.map((w, i) => { const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0; const x = i * (barWidth + barGap); return ``; }).join(''); const labelsHTML = labels.map(l => { const x = l.i * (barWidth + barGap); return `${l.label}`; }).join(''); container.innerHTML = `
${barsHTML} ${labelsHTML} `; // Tooltip on hover const tooltip = container.querySelector('#chart-tooltip'); container.querySelectorAll('.bar-fill').forEach(bar => { bar.addEventListener('mouseenter', e => { tooltip.textContent = bar.dataset.label; tooltip.classList.add('show'); }); bar.addEventListener('mousemove', e => { positionTooltip(tooltip, e.clientX, e.clientY); }); bar.addEventListener('mouseleave', () => tooltip.classList.remove('show')); }); } export function renderCumulativeChart(container, daily) { const sorted = [...daily].sort((a, b) => a.day.localeCompare(b.day)); if (!sorted.length) { container.innerHTML = '
No data
'; return; } let cumulative = 0; const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; }); const maxVal = points[points.length - 1].total; const chartWidth = 700; const chartHeight = 140; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; // Scale x by index, y by value const xScale = (i) => (i / (points.length - 1)) * chartWidth; const yScale = (v) => chartHeight - (v / maxVal) * chartHeight; // Build path const pathParts = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(p.total).toFixed(1)}`); const linePath = pathParts.join(' '); const areaPath = linePath + ` L${chartWidth},${chartHeight} L0,${chartHeight} Z`; // Month labels const labels = []; let lastMonth = -1; for (let i = 0; i < points.length; i++) { const m = new Date(points[i].day).getMonth(); if (m !== lastMonth) { labels.push({ x: xScale(i), label: months[m] }); lastMonth = m; } } const labelsHTML = labels.map(l => `${l.label}`).join(''); // Invisible hover dots for tooltip const dotsHTML = points.map((p, i) => { return ``; }).join(''); container.innerHTML = `
${dotsHTML} ${labelsHTML} `; const tooltip = container.querySelector('#chart-tooltip-cum'); container.querySelectorAll('.cumulative-dot').forEach(dot => { dot.addEventListener('mouseenter', e => { tooltip.textContent = dot.dataset.label; tooltip.classList.add('show'); }); dot.addEventListener('mousemove', e => { positionTooltip(tooltip, e.clientX, e.clientY); }); dot.addEventListener('mouseleave', () => tooltip.classList.remove('show')); }); }