// 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 = `
${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 = ``;
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 = ``;
if (newWorkspaces.length) {
html += `
${newWorkspaces.map(s => `
${escapeHTML(formatProjectLabel(s.project))} ${escapeHTML(s.title || '(untitled)')}
${s.message_count || 0} msg
`).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 += `
${newSessions.map(s => `
${escapeHTML(s.title || '(untitled)')}
${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg
`).join('')}
`;
}
if (continued.length) {
html += `
${continued.map(s => `
${escapeHTML(s.title || '(untitled)')}
${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg
`).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 = ``;
if (newWorkspaces.length) {
html += `
${newWorkspaces.map(s => `
${escapeHTML(formatProjectLabel(s.project))} ${escapeHTML(s.title || '(untitled)')}
${s.message_count || 0} msg
`).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 += `
${newSessions.map(s => `
${escapeHTML(s.title || '(untitled)')}
${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg
`).join('')}
`;
}
if (continued.length) {
html += `
${continued.map(s => `
${escapeHTML(s.title || '(untitled)')}
${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg
`).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'));
});
}