diff --git a/db.js b/db.js index 5a42ab2b..ef9d5ded 100644 --- a/db.js +++ b/db.js @@ -408,6 +408,26 @@ function deleteSetting(key) { stmts.settingsDelete.run(key); } +// --- Daily activity aggregate (for stats heatmap) --- + +// Returns [{date: 'YYYY-MM-DD', messageCount, sessionCount}, ...] sorted ASC. +// Aggregates ALL rows in session_cache (parent sessions + subagents) so the +// heatmap reflects real usage regardless of whether Claude rotated the parent +// JSONL files. +function getDailyActivity() { + return db.prepare(` + SELECT + substr(modified, 1, 10) AS date, + SUM(messageCount) AS messageCount, + COUNT(*) AS sessionCount + FROM session_cache + WHERE modified IS NOT NULL + AND length(modified) >= 10 + GROUP BY date + ORDER BY date ASC + `).all(); +} + function closeDb() { try { db.close(); } catch {} } @@ -421,5 +441,6 @@ module.exports = { upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, + getDailyActivity, closeDb, }; diff --git a/main.js b/main.js index 11a09721..19800549 100644 --- a/main.js +++ b/main.js @@ -77,6 +77,7 @@ const { upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, + getDailyActivity, closeDb, } = require('./db'); @@ -620,108 +621,69 @@ ipcMain.handle('get-stats', () => { } }); -// --- IPC: refresh-stats (run /stats + /usage via PTY) --- -ipcMain.handle('refresh-stats', async () => { - // For stats, use the configured shell profile - const globalSettings = getSetting('global') || {}; - const statsProfileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; - const statsShellProfile = resolveShell(statsProfileId); - const statsShell = statsShellProfile.path; - const statsShellExtraArgs = statsShellProfile.args || []; - const ptyEnv = { - ...cleanPtyEnv, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'iTerm.app', - TERM_PROGRAM_VERSION: '3.6.6', - FORCE_COLOR: '3', - ITERM_SESSION_ID: '1', - }; - - // Helper: spawn claude with args, collect output, auto-accept trust, kill when idle - // waitFor: optional regex tested against stripped output — finish only when matched - function runClaude(args, { timeoutMs = 15000, waitFor = null } = {}) { - return new Promise((resolve) => { - let output = ''; - let settled = false; - let trustAccepted = false; - // Track idle: ✳ in OSC title means Claude is idle and waiting for input - let sawActivity = false; - - const finish = () => { - if (settled) return; - settled = true; - try { p.kill(); } catch {} - resolve(output); - }; - - const claudeCmd = `claude ${args}`; - const p = pty.spawn(statsShell, shellArgs(statsShell, claudeCmd, statsShellExtraArgs), { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: os.homedir(), - env: ptyEnv, - }); - - const strip = (s) => s - .replace(/\x1b\[[^@-~]*[@-~]/g, '') - .replace(/\x1b\][^\x07]*\x07/g, '') - .replace(/\x1b[^[\]].?/g, ''); - - p.onData((data) => { - output += data; - - // Auto-accept trust directory prompt (Enter selects "1. Yes") - if (!trustAccepted) { - if (/trust\s*this\s*folder/i.test(strip(output))) { - trustAccepted = true; - try { p.write('\r'); } catch {} - return; - } - } - - // If waitFor is set, finish when that pattern appears in stripped output - if (waitFor) { - if (waitFor.test(strip(output))) { - finish(); - } - return; - } - - // Default: detect busy→idle transition via OSC title containing ✳ - if (!sawActivity) { - const oscTitle = data.match(/\x1b\]0;([^\x07\x1b]*)/); - if (oscTitle) { - const first = oscTitle[1].charAt(0); - if (first.charCodeAt(0) >= 0x2800 && first.charCodeAt(0) <= 0x28FF) { - sawActivity = true; - } - } - } else if (data.includes('\u2733')) { - finish(); - } - }); - - p.onExit(() => finish()); - setTimeout(finish, timeoutMs); - }); +// --- IPC: get-stats-from-db --- +// Builds a stats object from session_cache so the heatmap reflects real usage +// including subagent sessions (which claude /stats silently ignores) and +// periods where Claude already rotated the parent JSONL files off disk. +ipcMain.handle('get-stats-from-db', () => { + try { + const rows = getDailyActivity(); // [{date, messageCount, sessionCount}] + let totalMessages = 0; + let totalSessions = 0; + let firstSessionDate = null; + for (const row of rows) { + totalMessages += row.messageCount || 0; + totalSessions += row.sessionCount || 0; + if (!firstSessionDate) firstSessionDate = row.date; + } + const lastComputedDate = new Date().toISOString().slice(0, 10); + return { + dailyActivity: rows, // [{date, messageCount, sessionCount}] + totalMessages, + totalSessions, + firstSessionDate: firstSessionDate || lastComputedDate, + lastComputedDate, + // dailyModelTokens intentionally omitted — not tracked per-day in session_cache + modelUsage: {}, + }; + } catch (err) { + log.error('Error building stats from DB:', err); + return null; } +}); +// --- IPC: refresh-stats (fetch /usage + build stats from DB; /stats PTY removed) --- +ipcMain.handle('refresh-stats', async () => { try { - // Run /stats via PTY (for heatmap/chart data) and fetch usage via API in parallel - const [, usage] = await Promise.all([ - runClaude('"/stats"', { waitFor: /streak/i, timeoutMs: 10000 }), - fetchAndTransformUsage().catch(() => ({})), - ]); + // /stats PTY call removed — heatmap is now sourced from session_cache via + // get-stats-from-db. Only /usage is fetched here (rate-limits panel). + const usage = await fetchAndTransformUsage().catch(() => ({})); - // Read refreshed stats cache + // Build stats from DB (same as get-stats-from-db) so the caller gets both + // at once and the renderer can update heatmap + usage in a single round-trip. let stats = null; try { - if (fs.existsSync(STATS_CACHE_PATH)) { - stats = JSON.parse(fs.readFileSync(STATS_CACHE_PATH, 'utf8')); + const rows = getDailyActivity(); + let totalMessages = 0; + let totalSessions = 0; + let firstSessionDate = null; + for (const row of rows) { + totalMessages += row.messageCount || 0; + totalSessions += row.sessionCount || 0; + if (!firstSessionDate) firstSessionDate = row.date; } - } catch {} + const lastComputedDate = new Date().toISOString().slice(0, 10); + stats = { + dailyActivity: rows, + totalMessages, + totalSessions, + firstSessionDate: firstSessionDate || lastComputedDate, + lastComputedDate, + modelUsage: {}, + }; + } catch (dbErr) { + log.error('Error building stats from DB in refresh-stats:', dbErr); + } return { stats, usage: usage || {} }; } catch (err) { diff --git a/preload.js b/preload.js index 7ace90e1..db71964c 100644 --- a/preload.js +++ b/preload.js @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('api', { readPlan: (filename) => ipcRenderer.invoke('read-plan', filename), savePlan: (filePath, content) => ipcRenderer.invoke('save-plan', filePath, content), getStats: () => ipcRenderer.invoke('get-stats'), + getStatsFromDb: () => ipcRenderer.invoke('get-stats-from-db'), refreshStats: () => ipcRenderer.invoke('refresh-stats'), getUsage: () => ipcRenderer.invoke('get-usage'), getMemories: () => ipcRenderer.invoke('get-memories'), diff --git a/public/stats-view.js b/public/stats-view.js index 11e04b69..0b5608ed 100644 --- a/public/stats-view.js +++ b/public/stats-view.js @@ -6,13 +6,14 @@ let cachedUsage = null; async function loadStats() { statsViewerBody.innerHTML = ''; - // Show spinner while refreshing + // Show spinner while fetching usage via PTY const spinner = document.createElement('div'); spinner.className = 'stats-spinner'; spinner.innerHTML = `
Updating stats\u2026`; statsViewerBody.appendChild(spinner); - // Refresh stats cache via PTY (/stats + /usage) + // Fetch stats from DB (instant) and usage from API (PTY) in parallel via + // refresh-stats, which now skips the slow /stats PTY call entirely. let stats, usage; try { const result = await window.api.refreshStats(); @@ -20,8 +21,8 @@ async function loadStats() { usage = result?.usage || {}; cachedUsage = usage; } catch { - // Fallback to cached stats - stats = await window.api.getStats(); + // Fallback: read DB directly for heatmap, use last cached usage + stats = await window.api.getStatsFromDb(); usage = cachedUsage || {}; } @@ -33,9 +34,9 @@ async function loadStats() { } if (stats) { - // dailyActivity may be an array of {date, messageCount, ...} or an object - const rawDaily = stats.dailyActivity || {}; - let dailyMap = {}; + // dailyActivity is an array of {date, messageCount, sessionCount} + const rawDaily = stats.dailyActivity || []; + const dailyMap = {}; if (Array.isArray(rawDaily)) { for (const entry of rawDaily) { dailyMap[entry.date] = entry.messageCount || 0; @@ -59,7 +60,7 @@ async function loadStats() { const notice = document.createElement('div'); notice.className = 'stats-notice'; const lastDate = stats.lastComputedDate || 'unknown'; - notice.innerHTML = `Data sourced from Claude\u2019s stats cache (last updated ${escapeHtml(lastDate)}).`; + notice.innerHTML = `Data sourced from Switchboard session cache (last updated ${escapeHtml(lastDate)}).`; statsViewerBody.appendChild(notice); } } diff --git a/test/db-daily-activity.test.js b/test/db-daily-activity.test.js new file mode 100644 index 00000000..1c8e79e7 --- /dev/null +++ b/test/db-daily-activity.test.js @@ -0,0 +1,122 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// getDailyActivity is powered by better-sqlite3, which is compiled against +// Electron's Node ABI and cannot be required from plain node:test. These tests +// therefore validate the aggregation LOGIC by running the same computation on +// plain JS arrays — the SQL query is a straightforward GROUP BY that we mirror +// here so regressions in the computation surface in CI. + +/** + * Pure-JS mirror of the getDailyActivity SQL: + * SELECT substr(modified,1,10) AS date, SUM(messageCount), COUNT(*) AS sessionCount + * FROM session_cache WHERE modified IS NOT NULL AND length(modified) >= 10 + * GROUP BY date ORDER BY date ASC + */ +function aggregateDailyActivity(rows) { + const map = new Map(); + for (const row of rows) { + if (!row.modified || row.modified.length < 10) continue; + const date = row.modified.slice(0, 10); + const existing = map.get(date); + if (existing) { + existing.messageCount += row.messageCount || 0; + existing.sessionCount += 1; + } else { + map.set(date, { date, messageCount: row.messageCount || 0, sessionCount: 1 }); + } + } + return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +test('aggregateDailyActivity returns [] when input is empty', () => { + assert.deepEqual(aggregateDailyActivity([]), []); +}); + +test('aggregateDailyActivity sums messageCount and counts sessions per day', () => { + const rows = [ + { sessionId: 's1', modified: '2026-03-15T10:05:00.000Z', messageCount: 10 }, + { sessionId: 's2', modified: '2026-03-15T11:30:00.000Z', messageCount: 5 }, + { sessionId: 's3', modified: '2026-03-16T09:45:00.000Z', messageCount: 20 }, + ]; + const result = aggregateDailyActivity(rows); + assert.equal(result.length, 2); + const mar15 = result.find(r => r.date === '2026-03-15'); + const mar16 = result.find(r => r.date === '2026-03-16'); + assert.ok(mar15); + assert.ok(mar16); + assert.equal(mar15.messageCount, 15); + assert.equal(mar15.sessionCount, 2); + assert.equal(mar16.messageCount, 20); + assert.equal(mar16.sessionCount, 1); +}); + +test('aggregateDailyActivity includes subagent rows (parentSessionId present)', () => { + const rows = [ + { sessionId: 'parent-1', modified: '2026-04-01T08:10:00.000Z', messageCount: 3, parentSessionId: null }, + { sessionId: 'sub:parent-1:agent-1', modified: '2026-04-01T08:20:00.000Z', messageCount: 8, parentSessionId: 'parent-1' }, + ]; + const result = aggregateDailyActivity(rows); + assert.equal(result.length, 1); + assert.equal(result[0].date, '2026-04-01'); + assert.equal(result[0].messageCount, 11, 'parent + subagent messages should both count'); + assert.equal(result[0].sessionCount, 2, 'parent + subagent sessions should both count'); +}); + +test('aggregateDailyActivity sorts results chronologically', () => { + const rows = [ + { sessionId: 'x1', modified: '2026-02-10T01:00:00.000Z', messageCount: 1 }, + { sessionId: 'x2', modified: '2026-05-01T01:00:00.000Z', messageCount: 1 }, + { sessionId: 'x3', modified: '2026-03-20T01:00:00.000Z', messageCount: 1 }, + ]; + const result = aggregateDailyActivity(rows); + assert.deepEqual(result.map(r => r.date), ['2026-02-10', '2026-03-20', '2026-05-01']); +}); + +test('aggregateDailyActivity skips rows with null or short modified', () => { + const rows = [ + { sessionId: 'a', modified: null, messageCount: 99 }, + { sessionId: 'b', modified: '2026', messageCount: 99 }, + { sessionId: 'c', modified: '2026-05-22T00:00:00.000Z', messageCount: 5 }, + ]; + const result = aggregateDailyActivity(rows); + assert.equal(result.length, 1); + assert.equal(result[0].date, '2026-05-22'); + assert.equal(result[0].messageCount, 5); +}); + +// Verify the stats-object shape built from getDailyActivity results (mirrors +// the get-stats-from-db / refresh-stats IPC handler logic). +test('stats object built from daily rows has correct totals and shape', () => { + const rows = [ + { date: '2026-03-15', messageCount: 15, sessionCount: 2 }, + { date: '2026-03-16', messageCount: 20, sessionCount: 1 }, + { date: '2026-04-01', messageCount: 11, sessionCount: 2 }, + ]; + + let totalMessages = 0; + let totalSessions = 0; + let firstSessionDate = null; + for (const row of rows) { + totalMessages += row.messageCount || 0; + totalSessions += row.sessionCount || 0; + if (!firstSessionDate) firstSessionDate = row.date; + } + const lastComputedDate = '2026-05-22'; + const stats = { + dailyActivity: rows, + totalMessages, + totalSessions, + firstSessionDate: firstSessionDate || lastComputedDate, + lastComputedDate, + modelUsage: {}, + }; + + assert.equal(stats.totalMessages, 46); + assert.equal(stats.totalSessions, 5); + assert.equal(stats.firstSessionDate, '2026-03-15'); + assert.equal(stats.lastComputedDate, '2026-05-22'); + assert.deepEqual(stats.modelUsage, {}); + assert.equal(stats.dailyActivity.length, 3); + assert.ok(Array.isArray(stats.dailyActivity)); +});