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
21 changes: 21 additions & 0 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
Expand All @@ -421,5 +441,6 @@ module.exports = {
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
getDailyActivity,
closeDb,
};
152 changes: 57 additions & 95 deletions main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { Worker } = require('worker_threads');

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
Expand All @@ -16,7 +16,7 @@
}

// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { fetchAndTransformUsage } = require('./claude-auth');
log.transports.file.level = app.isPackaged ? 'info' : 'debug';
log.transports.console.level = app.isPackaged ? 'info' : 'debug';
Expand All @@ -36,7 +36,7 @@
);

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles');

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');

Expand Down Expand Up @@ -73,10 +73,11 @@
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
deleteCachedSession, deleteCachedFolder,
getFolderMeta, getAllFolderMeta, setFolderMeta,

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
getDailyActivity,
closeDb,
} = require('./db');

Expand Down Expand Up @@ -288,8 +289,8 @@
setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem,

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache;

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u


// --- IPC: browse-folder ---
Expand Down Expand Up @@ -620,108 +621,69 @@
}
});

// --- 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) {
Expand Down Expand Up @@ -1165,7 +1127,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 1130 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 1130 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
17 changes: 9 additions & 8 deletions public/stats-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@ 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 = `<div class="stats-spinner-icon"></div><span>Updating stats\u2026</span>`;
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();
stats = result?.stats;
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 || {};
}

Expand All @@ -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;
Expand All @@ -59,7 +60,7 @@ async function loadStats() {
const notice = document.createElement('div');
notice.className = 'stats-notice';
const lastDate = stats.lastComputedDate || 'unknown';
notice.innerHTML = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" style="vertical-align:-2px;margin-right:6px;flex-shrink:0"><circle cx="8" cy="8" r="7"/><line x1="8" y1="5" x2="8" y2="9"/><circle cx="8" cy="11.5" r="0.5" fill="currentColor" stroke="none"/></svg>Data sourced from Claude\u2019s stats cache (last updated ${escapeHtml(lastDate)}).`;
notice.innerHTML = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" style="vertical-align:-2px;margin-right:6px;flex-shrink:0"><circle cx="8" cy="8" r="7"/><line x1="8" y1="5" x2="8" y2="9"/><circle cx="8" cy="11.5" r="0.5" fill="currentColor" stroke="none"/></svg>Data sourced from Switchboard session cache (last updated ${escapeHtml(lastDate)}).`;
statsViewerBody.appendChild(notice);
}
}
Expand Down
122 changes: 122 additions & 0 deletions test/db-daily-activity.test.js
Original file line number Diff line number Diff line change
@@ -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));
});
Loading