## CHANGES

- Added session lifecycle tracking with created/closed events and durations 🚀
- Introduced `session_lifecycle` SQLite table plus indexes via v3 migration 🗄️
- Expanded stats aggregation with total sessions, per-agent, per-day rollups 📊
- Usage Dashboard gained a new “Sessions” summary card (now six metrics) 🧩
- New IPC endpoints to record sessions and fetch lifecycle history cleanly 🔌
- Session create/close events now fire automatically from the main app flow 🧠
- Old-data cleanup now purges session lifecycle rows for leaner databases 🧹
- Stats collection toggle respected for session-created recording, avoiding noise 🔒
- Updated typings across shared/renderer APIs for new session stats fields 🧾
- Bumped package version to 0.14.1 for this stats upgrade release 🎉
This commit is contained in:
Pedram Amini
2026-01-02 14:37:38 -06:00
parent 581dec2cb9
commit 66b2895d90
16 changed files with 686 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "maestro",
"version": "0.14.0",
"version": "0.14.1",
"description": "Maestro hones fractured attention into focused intent.",
"main": "dist/main/index.js",
"author": {

View File

@@ -60,9 +60,20 @@ describe('stats IPC handlers', () => {
avgDuration: 0,
byAgent: {},
bySource: { user: 0, auto: 0 },
byLocation: { local: 0, remote: 0 },
byDay: [],
byHour: [],
totalSessions: 0,
sessionsByAgent: {},
sessionsByDay: [],
avgSessionDuration: 0,
}),
exportToCsv: vi.fn().mockReturnValue('id,sessionId,...'),
clearOldData: vi.fn().mockReturnValue({ success: true, deletedCount: 0 }),
getDatabaseSize: vi.fn().mockReturnValue({ sizeBytes: 1024, sizeFormatted: '1 KB' }),
recordSessionCreated: vi.fn().mockReturnValue('session-lifecycle-id'),
recordSessionClosed: vi.fn().mockReturnValue(true),
getSessionLifecycleEvents: vi.fn().mockReturnValue([]),
};
vi.mocked(statsDbModule.getStatsDB).mockReturnValue(mockStatsDB as unknown as StatsDB);
@@ -103,6 +114,11 @@ describe('stats IPC handlers', () => {
'stats:get-autorun-tasks',
'stats:get-aggregation',
'stats:export-csv',
'stats:clear-old-data',
'stats:get-database-size',
'stats:record-session-created',
'stats:record-session-closed',
'stats:get-session-lifecycle',
];
for (const channel of expectedChannels) {
@@ -375,4 +391,109 @@ describe('stats IPC handlers', () => {
expect(mockMainWindow.webContents.send).toHaveBeenNthCalledWith(4, 'stats:updated');
});
});
describe('session lifecycle handlers', () => {
describe('stats:record-session-created', () => {
it('should broadcast stats:updated after recording session created', async () => {
const handler = handlers.get('stats:record-session-created');
const lifecycleEvent = {
sessionId: 'session-1',
agentType: 'claude-code',
projectPath: '/test/project',
createdAt: Date.now(),
isRemote: false,
};
const result = await handler!({} as any, lifecycleEvent);
expect(result).toBe('session-lifecycle-id');
expect(mockStatsDB.recordSessionCreated).toHaveBeenCalledWith(lifecycleEvent);
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
expect(mockMainWindow.webContents.send).toHaveBeenCalledTimes(1);
});
it('should not broadcast when main window is null', async () => {
const nullWindowGetMainWindow = () => null;
handlers.clear();
vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => {
handlers.set(channel, handler);
});
registerStatsHandlers({ getMainWindow: nullWindowGetMainWindow });
const handler = handlers.get('stats:record-session-created');
const lifecycleEvent = {
sessionId: 'session-1',
agentType: 'claude-code',
createdAt: Date.now(),
};
await handler!({} as any, lifecycleEvent);
expect(mockStatsDB.recordSessionCreated).toHaveBeenCalled();
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled();
});
});
describe('stats:record-session-closed', () => {
it('should broadcast stats:updated after recording session closed', async () => {
const handler = handlers.get('stats:record-session-closed');
const sessionId = 'session-1';
const closedAt = Date.now();
const result = await handler!({} as any, sessionId, closedAt);
expect(result).toBe(true);
expect(mockStatsDB.recordSessionClosed).toHaveBeenCalledWith(sessionId, closedAt);
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
expect(mockMainWindow.webContents.send).toHaveBeenCalledTimes(1);
});
it('should broadcast stats:updated even when session not found', async () => {
vi.mocked(mockStatsDB.recordSessionClosed).mockReturnValue(false);
const handler = handlers.get('stats:record-session-closed');
const result = await handler!({} as any, 'nonexistent-session', Date.now());
expect(result).toBe(false);
// Should still broadcast - UI may need to refresh regardless
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
});
});
describe('stats:get-session-lifecycle', () => {
it('should not broadcast stats:updated when getting session lifecycle events', async () => {
const handler = handlers.get('stats:get-session-lifecycle');
await handler!({} as any, 'week');
expect(mockStatsDB.getSessionLifecycleEvents).toHaveBeenCalledWith('week');
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled();
});
});
});
describe('clear old data handler', () => {
describe('stats:clear-old-data', () => {
it('should broadcast stats:updated after clearing old data', async () => {
const handler = handlers.get('stats:clear-old-data');
const result = await handler!({} as any, 30);
expect(result).toEqual({ success: true, deletedCount: 0 });
expect(mockStatsDB.clearOldData).toHaveBeenCalledWith(30);
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
});
it('should not broadcast when clear fails', async () => {
vi.mocked(mockStatsDB.clearOldData).mockReturnValue({ success: false, deletedCount: 0 });
const handler = handlers.get('stats:clear-old-data');
const result = await handler!({} as any, 30);
expect(result).toEqual({ success: false, deletedCount: 0 });
// Should not broadcast on failure
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -93,6 +93,7 @@ import type {
QueryEvent,
AutoRunSession,
AutoRunTask,
SessionLifecycleEvent,
StatsTimeRange,
StatsFilters,
StatsAggregation,
@@ -188,6 +189,58 @@ describe('stats-types.ts', () => {
});
});
describe('SessionLifecycleEvent interface', () => {
it('should define proper SessionLifecycleEvent structure for created session', () => {
const event: SessionLifecycleEvent = {
id: 'lifecycle-1',
sessionId: 'session-1',
agentType: 'claude-code',
projectPath: '/test/project',
createdAt: Date.now(),
isRemote: false,
};
expect(event.id).toBe('lifecycle-1');
expect(event.sessionId).toBe('session-1');
expect(event.agentType).toBe('claude-code');
expect(event.closedAt).toBeUndefined();
expect(event.duration).toBeUndefined();
});
it('should define proper SessionLifecycleEvent structure for closed session', () => {
const createdAt = Date.now() - 3600000; // 1 hour ago
const closedAt = Date.now();
const event: SessionLifecycleEvent = {
id: 'lifecycle-2',
sessionId: 'session-2',
agentType: 'claude-code',
projectPath: '/test/project',
createdAt,
closedAt,
duration: closedAt - createdAt,
isRemote: true,
};
expect(event.closedAt).toBe(closedAt);
expect(event.duration).toBe(3600000);
expect(event.isRemote).toBe(true);
});
it('should allow optional fields to be undefined', () => {
const event: SessionLifecycleEvent = {
id: 'lifecycle-3',
sessionId: 'session-3',
agentType: 'opencode',
createdAt: Date.now(),
};
expect(event.projectPath).toBeUndefined();
expect(event.closedAt).toBeUndefined();
expect(event.duration).toBeUndefined();
expect(event.isRemote).toBeUndefined();
});
});
describe('StatsTimeRange type', () => {
it('should accept valid time ranges', () => {
const ranges: StatsTimeRange[] = ['day', 'week', 'month', 'year', 'all'];
@@ -221,16 +274,37 @@ describe('stats-types.ts', () => {
opencode: { count: 30, duration: 150000 },
},
bySource: { user: 60, auto: 40 },
byLocation: { local: 80, remote: 20 },
byDay: [
{ date: '2024-01-01', count: 10, duration: 50000 },
{ date: '2024-01-02', count: 15, duration: 75000 },
],
byHour: [
{ hour: 9, count: 20, duration: 100000 },
{ hour: 10, count: 25, duration: 125000 },
],
// Session lifecycle fields
totalSessions: 15,
sessionsByAgent: {
'claude-code': 10,
opencode: 5,
},
sessionsByDay: [
{ date: '2024-01-01', count: 3 },
{ date: '2024-01-02', count: 5 },
],
avgSessionDuration: 1800000,
};
expect(aggregation.totalQueries).toBe(100);
expect(aggregation.byAgent['claude-code'].count).toBe(70);
expect(aggregation.bySource.user).toBe(60);
expect(aggregation.byDay).toHaveLength(2);
// Session lifecycle assertions
expect(aggregation.totalSessions).toBe(15);
expect(aggregation.sessionsByAgent['claude-code']).toBe(10);
expect(aggregation.sessionsByDay).toHaveLength(2);
expect(aggregation.avgSessionDuration).toBe(1800000);
});
});
});
@@ -414,13 +488,13 @@ describe('StatsDB class (mocked)', () => {
const db = new StatsDB();
db.initialize();
// Currently we have version 2 migration (v1: initial schema, v2: is_remote column)
expect(db.getTargetVersion()).toBe(2);
// Currently we have version 3 migration (v1: initial schema, v2: is_remote column, v3: session_lifecycle table)
expect(db.getTargetVersion()).toBe(3);
});
it('should return false from hasPendingMigrations() when up to date', async () => {
mockDb.pragma.mockImplementation((sql: string) => {
if (sql === 'user_version') return [{ user_version: 2 }];
if (sql === 'user_version') return [{ user_version: 3 }];
return undefined;
});
@@ -435,8 +509,8 @@ describe('StatsDB class (mocked)', () => {
// This test verifies the hasPendingMigrations() logic
// by checking current version < target version
// Simulate a database that's already at version 2 (target version)
let currentVersion = 2;
// Simulate a database that's already at version 3 (target version)
let currentVersion = 3;
mockDb.pragma.mockImplementation((sql: string) => {
if (sql === 'user_version') return [{ user_version: currentVersion }];
// Handle version updates from migration
@@ -450,9 +524,9 @@ describe('StatsDB class (mocked)', () => {
const db = new StatsDB();
db.initialize();
// At version 2, target is 2, so no pending migrations
expect(db.getCurrentVersion()).toBe(2);
expect(db.getTargetVersion()).toBe(2);
// At version 3, target is 3, so no pending migrations
expect(db.getCurrentVersion()).toBe(3);
expect(db.getTargetVersion()).toBe(3);
expect(db.hasPendingMigrations()).toBe(false);
});
@@ -8240,12 +8314,15 @@ describe('Database VACUUM functionality', () => {
db.clearOldData(30);
// All DELETE queries should filter by start_time (indexed)
// All DELETE queries should filter by indexed time columns
// (start_time for query_events, auto_run_sessions, auto_run_tasks;
// created_at for session_lifecycle)
const deleteQueries = queriesExecuted.filter((sql) => sql.includes('DELETE'));
expect(deleteQueries.length).toBeGreaterThan(0);
for (const query of deleteQueries) {
expect(query).toContain('start_time <');
const usesIndexedTimeColumn = query.includes('start_time <') || query.includes('created_at <');
expect(usesIndexedTimeColumn).toBe(true);
}
});
});
@@ -8475,8 +8552,10 @@ describe('Database VACUUM functionality', () => {
for (const query of selectQueries) {
// Each query should filter by an indexed column
// (includes created_at for session_lifecycle table)
const hasIndexedFilter =
query.includes('start_time') ||
query.includes('created_at') ||
query.includes('auto_run_session_id') ||
query.includes('session_id');
expect(hasIndexedFilter).toBe(true);

View File

@@ -2,7 +2,7 @@
* Tests for SummaryCards component
*
* Verifies:
* - Renders all five metric cards correctly
* - Renders all six metric cards correctly
* - Displays formatted values (numbers, durations)
* - Shows correct icons for each metric
* - Applies theme colors properly
@@ -30,10 +30,16 @@ const mockData: StatsAggregation = {
'aider': { count: 50, duration: 2200000 },
},
bySource: { user: 120, auto: 30 },
byLocation: { local: 120, remote: 30 },
byDay: [
{ date: '2024-12-20', count: 50, duration: 2400000 },
{ date: '2024-12-21', count: 100, duration: 4800000 },
],
byHour: [],
totalSessions: 25,
sessionsByAgent: { 'claude-code': 15, 'aider': 10 },
sessionsByDay: [],
avgSessionDuration: 288000,
};
// Empty data for edge case testing
@@ -43,7 +49,13 @@ const emptyData: StatsAggregation = {
avgDuration: 0,
byAgent: {},
bySource: { user: 0, auto: 0 },
byLocation: { local: 0, remote: 0 },
byDay: [],
byHour: [],
totalSessions: 0,
sessionsByAgent: {},
sessionsByDay: [],
avgSessionDuration: 0,
};
// Data with large numbers
@@ -56,7 +68,13 @@ const largeNumbersData: StatsAggregation = {
'openai-codex': { count: 500000, duration: 160000000 },
},
bySource: { user: 1200000, auto: 300000 },
byLocation: { local: 1000000, remote: 500000 },
byDay: [],
byHour: [],
totalSessions: 50000,
sessionsByAgent: { 'claude-code': 30000, 'openai-codex': 20000 },
sessionsByDay: [],
avgSessionDuration: 7200000,
};
// Single agent data
@@ -68,7 +86,13 @@ const singleAgentData: StatsAggregation = {
'terminal': { count: 50, duration: 1800000 },
},
bySource: { user: 50, auto: 0 },
byLocation: { local: 50, remote: 0 },
byDay: [],
byHour: [],
totalSessions: 5,
sessionsByAgent: { 'terminal': 5 },
sessionsByDay: [],
avgSessionDuration: 360000,
};
// Only auto queries
@@ -80,7 +104,13 @@ const onlyAutoData: StatsAggregation = {
'claude-code': { count: 100, duration: 3600000 },
},
bySource: { user: 0, auto: 100 },
byLocation: { local: 100, remote: 0 },
byDay: [],
byHour: [],
totalSessions: 10,
sessionsByAgent: { 'claude-code': 10 },
sessionsByDay: [],
avgSessionDuration: 360000,
};
describe('SummaryCards', () => {
@@ -91,11 +121,11 @@ describe('SummaryCards', () => {
expect(screen.getByTestId('summary-cards')).toBeInTheDocument();
});
it('renders all five metric cards', () => {
it('renders all six metric cards', () => {
render(<SummaryCards data={mockData} theme={theme} />);
const cards = screen.getAllByTestId('metric-card');
expect(cards).toHaveLength(5);
expect(cards).toHaveLength(6);
});
it('renders Total Queries metric', () => {
@@ -291,18 +321,18 @@ describe('SummaryCards', () => {
// Each card should have an SVG icon
const svgElements = container.querySelectorAll('svg');
expect(svgElements.length).toBe(5);
expect(svgElements.length).toBe(6);
});
});
describe('Grid Layout', () => {
it('uses 5-column grid layout by default', () => {
it('uses 6-column grid layout by default', () => {
render(<SummaryCards data={mockData} theme={theme} />);
const container = screen.getByTestId('summary-cards');
expect(container).toHaveClass('grid');
expect(container).toHaveStyle({
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
});
});
@@ -331,7 +361,8 @@ describe('SummaryCards', () => {
// Should render without errors
expect(screen.getByTestId('summary-cards')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
// Multiple cards show '0' for empty data (Sessions, Queries)
expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(1);
});
it('handles very large numbers', () => {

View File

@@ -50,11 +50,24 @@ const mockStatsData: StatsAggregation = {
user: 120,
auto: 30,
},
byLocation: { local: 120, remote: 30 },
byDay: [
{ date: '2025-01-20', count: 50, duration: 2400000 },
{ date: '2025-01-21', count: 45, duration: 2160000 },
{ date: '2025-01-22', count: 55, duration: 2640000 },
],
byHour: [
{ hour: 9, count: 50, duration: 2400000 },
{ hour: 14, count: 100, duration: 4800000 },
],
totalSessions: 25,
sessionsByAgent: { 'claude-code': 15, 'opencode': 6, 'gemini-cli': 4 },
sessionsByDay: [
{ date: '2025-01-20', count: 8 },
{ date: '2025-01-21', count: 9 },
{ date: '2025-01-22', count: 8 },
],
avgSessionDuration: 288000,
};
describe('Chart Accessibility - AgentComparisonChart', () => {
@@ -301,7 +314,7 @@ describe('Chart Accessibility - SummaryCards', () => {
it('each metric card has role="group"', () => {
render(<SummaryCards data={mockStatsData} theme={mockTheme} />);
const groups = screen.getAllByRole('group');
expect(groups).toHaveLength(5); // 5 metric cards
expect(groups).toHaveLength(6); // 6 metric cards
});
it('metric cards have descriptive aria-labels', () => {
@@ -309,6 +322,7 @@ describe('Chart Accessibility - SummaryCards', () => {
const groups = screen.getAllByRole('group');
const expectedLabels = [
/Sessions/i,
/Total Queries/i,
/Total Time/i,
/Avg Duration/i,
@@ -376,7 +390,13 @@ describe('Chart Accessibility - General ARIA Patterns', () => {
avgDuration: 0,
byAgent: {},
bySource: { user: 0, auto: 0 },
byLocation: { local: 0, remote: 0 },
byDay: [],
byHour: [],
totalSessions: 0,
sessionsByAgent: {},
sessionsByDay: [],
avgSessionDuration: 0,
};
render(<AgentComparisonChart data={emptyData} theme={mockTheme} />);

View File

@@ -3,9 +3,11 @@
*
* These tests verify that the Usage Dashboard modal correctly adapts its layout
* based on container width, supporting:
* - Narrow screens (<600px): Single column charts, 2-column summary cards
* - Medium screens (600-900px): 2-column charts, 3-column summary cards
* - Wide screens (>900px): 2-column charts, 5-column summary cards
* - Narrow screens (<600px): Single column charts, 2-column summary cards grid
* - Medium screens (600-900px): 2-column charts, 3-column summary cards grid
* - Wide screens (>900px): 2-column charts, 5-column summary cards grid
*
* Note: SummaryCards always renders 6 metric cards regardless of grid column count.
*
* The responsive system uses ResizeObserver to track container width and
* dynamically adjusts grid column counts via CSS grid.
@@ -36,10 +38,14 @@ vi.mock('lucide-react', () => {
Timer: createIcon('timer', '⏱️'),
Bot: createIcon('bot', '🤖'),
Users: createIcon('users', '👥'),
Layers: createIcon('layers', '📚'),
Play: createIcon('play', '▶️'),
CheckSquare: createIcon('check-square', '✅'),
ListChecks: createIcon('list-checks', '📝'),
Target: createIcon('target', '🎯'),
AlertTriangle: createIcon('alert-triangle', '⚠️'),
ChevronDown: createIcon('chevron-down', '▼'),
ChevronUp: createIcon('chevron-up', '▲'),
};
});
@@ -181,12 +187,27 @@ const createSampleData = () => ({
'terminal': { count: 50, duration: 1200000 },
},
bySource: { user: 100, auto: 50 },
byLocation: { local: 120, remote: 30 },
byDay: [
{ date: '2024-01-15', count: 25, duration: 600000 },
{ date: '2024-01-16', count: 30, duration: 720000 },
{ date: '2024-01-17', count: 45, duration: 1080000 },
{ date: '2024-01-18', count: 50, duration: 1200000 },
],
byHour: [
{ hour: 9, count: 40, duration: 960000 },
{ hour: 14, count: 60, duration: 1440000 },
{ hour: 15, count: 50, duration: 1200000 },
],
totalSessions: 25,
sessionsByAgent: { 'claude-code': 15, 'terminal': 10 },
sessionsByDay: [
{ date: '2024-01-15', count: 5 },
{ date: '2024-01-16', count: 7 },
{ date: '2024-01-17', count: 6 },
{ date: '2024-01-18', count: 7 },
],
avgSessionDuration: 144000,
});
describe('UsageDashboard Responsive Layout', () => {
@@ -378,7 +399,7 @@ describe('UsageDashboard Responsive Layout', () => {
});
});
it('renders all 5 metric cards regardless of column count', async () => {
it('renders all 6 metric cards regardless of column count', async () => {
render(
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
);
@@ -387,9 +408,9 @@ describe('UsageDashboard Responsive Layout', () => {
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
});
// Should always have 5 metric cards
// Should always have 6 metric cards
const metricCards = screen.getAllByTestId('metric-card');
expect(metricCards).toHaveLength(5);
expect(metricCards).toHaveLength(6);
});
});

View File

@@ -38,10 +38,14 @@ vi.mock('lucide-react', () => {
Timer: createIcon('timer', '⏱️'),
Bot: createIcon('bot', '🤖'),
Users: createIcon('users', '👥'),
Layers: createIcon('layers', '📚'),
Play: createIcon('play', '▶️'),
CheckSquare: createIcon('check-square', '✅'),
ListChecks: createIcon('list-checks', '📝'),
Target: createIcon('target', '🎯'),
AlertTriangle: createIcon('alert-triangle', '⚠️'),
ChevronDown: createIcon('chevron-down', '▼'),
ChevronUp: createIcon('chevron-up', '▲'),
};
});
@@ -136,10 +140,22 @@ beforeEach(() => {
'opencode': { count: 20, duration: 720000 },
},
bySource: { user: 70, auto: 30 },
byLocation: { local: 80, remote: 20 },
byDay: [
{ date: '2024-01-01', count: 50, duration: 1800000 },
{ date: '2024-01-02', count: 50, duration: 1800000 },
],
byHour: [
{ hour: 9, count: 40, duration: 1440000 },
{ hour: 14, count: 60, duration: 2160000 },
],
totalSessions: 20,
sessionsByAgent: { 'claude-code': 15, 'opencode': 5 },
sessionsByDay: [
{ date: '2024-01-01', count: 10 },
{ date: '2024-01-02', count: 10 },
],
avgSessionDuration: 180000,
});
mockStats.getDatabaseSize.mockResolvedValue(1024 * 1024); // 1 MB
});
@@ -271,7 +287,13 @@ describe('Usage Dashboard State Transition Animations', () => {
avgDuration: 36000,
byAgent: { 'claude-code': { count: 100, duration: 3600000 } },
bySource: { user: 70, auto: 30 },
byLocation: { local: 80, remote: 20 },
byDay: [],
byHour: [],
totalSessions: 15,
sessionsByAgent: { 'claude-code': 15 },
sessionsByDay: [],
avgSessionDuration: 240000,
};
it('applies dashboard-card-enter class to metric cards', () => {
@@ -287,7 +309,7 @@ describe('Usage Dashboard State Transition Animations', () => {
render(<SummaryCards data={mockData} theme={mockTheme} />);
const cards = screen.getAllByTestId('metric-card');
expect(cards.length).toBe(5); // 5 metric cards
expect(cards.length).toBe(6); // 6 metric cards
// Verify each card has incrementing animation delay
cards.forEach((card, index) => {
@@ -303,11 +325,11 @@ describe('Usage Dashboard State Transition Animations', () => {
expect(cards[0]).toHaveStyle({ animationDelay: '0ms' });
});
it('last card has 200ms delay (4 * 50ms)', () => {
it('last card has 250ms delay (5 * 50ms)', () => {
render(<SummaryCards data={mockData} theme={mockTheme} />);
const cards = screen.getAllByTestId('metric-card');
expect(cards[4]).toHaveStyle({ animationDelay: '200ms' });
expect(cards[5]).toHaveStyle({ animationDelay: '250ms' });
});
});

View File

@@ -31,11 +31,16 @@ vi.mock('lucide-react', () => {
Timer: createIcon('timer', '⏱️'),
Bot: createIcon('bot', '🤖'),
Users: createIcon('users', '👥'),
Layers: createIcon('layers', '📚'),
// AutoRunStats icons
Play: createIcon('play', '▶️'),
CheckSquare: createIcon('check-square', '✅'),
ListChecks: createIcon('list-checks', '📝'),
Target: createIcon('target', '🎯'),
// ChartErrorBoundary icons
AlertTriangle: createIcon('alert-triangle', '⚠️'),
ChevronDown: createIcon('chevron-down', '▼'),
ChevronUp: createIcon('chevron-up', '▲'),
};
});
@@ -116,12 +121,28 @@ const createSampleData = () => ({
'terminal': { count: 50, duration: 1200000 },
},
bySource: { user: 100, auto: 50 },
byLocation: { local: 120, remote: 30 },
byDay: [
{ date: '2024-01-15', count: 25, duration: 600000 },
{ date: '2024-01-16', count: 30, duration: 720000 },
{ date: '2024-01-17', count: 45, duration: 1080000 },
{ date: '2024-01-18', count: 50, duration: 1200000 },
],
byHour: [
{ hour: 9, count: 20, duration: 480000 },
{ hour: 10, count: 35, duration: 840000 },
{ hour: 14, count: 45, duration: 1080000 },
{ hour: 15, count: 50, duration: 1200000 },
],
totalSessions: 25,
sessionsByAgent: { 'claude-code': 15, 'terminal': 10 },
sessionsByDay: [
{ date: '2024-01-15', count: 5 },
{ date: '2024-01-16', count: 7 },
{ date: '2024-01-17', count: 6 },
{ date: '2024-01-18', count: 7 },
],
avgSessionDuration: 144000,
});
describe('UsageDashboardModal', () => {

View File

@@ -20,6 +20,7 @@ import {
QueryEvent,
AutoRunSession,
AutoRunTask,
SessionLifecycleEvent,
StatsTimeRange,
StatsFilters,
} from '../../../shared/stats-types';
@@ -241,4 +242,54 @@ export function registerStatsHandlers(deps: StatsHandlerDependencies): void {
return db.getDatabaseSize();
})
);
// Record session creation (launched)
ipcMain.handle(
'stats:record-session-created',
withIpcErrorLogging(
handlerOpts('recordSessionCreated'),
async (event: Omit<SessionLifecycleEvent, 'id' | 'closedAt' | 'duration'>) => {
// Check if stats collection is enabled
if (!isStatsCollectionEnabled(settingsStore)) {
logger.debug('Stats collection disabled, skipping session creation', LOG_CONTEXT);
return null;
}
const db = getStatsDB();
const id = db.recordSessionCreated(event);
logger.debug(`Recorded session created: ${event.sessionId}`, LOG_CONTEXT, {
agentType: event.agentType,
projectPath: event.projectPath,
});
broadcastStatsUpdate(getMainWindow);
return id;
}
)
);
// Record session closure
ipcMain.handle(
'stats:record-session-closed',
withIpcErrorLogging(
handlerOpts('recordSessionClosed'),
async (sessionId: string, closedAt: number) => {
const db = getStatsDB();
const updated = db.recordSessionClosed(sessionId, closedAt);
if (updated) {
logger.debug(`Recorded session closed: ${sessionId}`, LOG_CONTEXT);
}
broadcastStatsUpdate(getMainWindow);
return updated;
}
)
);
// Get session lifecycle events within a time range
ipcMain.handle(
'stats:get-session-lifecycle',
withIpcErrorLogging(handlerOpts('getSessionLifecycle'), async (range: StatsTimeRange) => {
const db = getStatsDB();
return db.getSessionLifecycleEvents(range);
})
);
}

View File

@@ -43,6 +43,7 @@ import {
QueryEvent,
AutoRunSession,
AutoRunTask,
SessionLifecycleEvent,
StatsTimeRange,
StatsFilters,
StatsAggregation,
@@ -253,6 +254,27 @@ const CREATE_AUTO_RUN_TASKS_INDEXES_SQL = `
CREATE INDEX IF NOT EXISTS idx_task_start ON auto_run_tasks(start_time)
`;
/**
* SQL for creating session_lifecycle table
*/
const CREATE_SESSION_LIFECYCLE_SQL = `
CREATE TABLE IF NOT EXISTS session_lifecycle (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE,
agent_type TEXT NOT NULL,
project_path TEXT,
created_at INTEGER NOT NULL,
closed_at INTEGER,
duration INTEGER,
is_remote INTEGER
)
`;
const CREATE_SESSION_LIFECYCLE_INDEXES_SQL = `
CREATE INDEX IF NOT EXISTS idx_session_created_at ON session_lifecycle(created_at);
CREATE INDEX IF NOT EXISTS idx_session_agent_type ON session_lifecycle(agent_type)
`;
/**
* StatsDB manages the SQLite database for usage statistics.
* Implements singleton pattern for database connection management.
@@ -279,6 +301,11 @@ export class StatsDB {
description: 'Add is_remote column to query_events for tracking SSH sessions',
up: () => this.migrateV2(),
},
{
version: 3,
description: 'Add session_lifecycle table for tracking session creation and closure',
up: () => this.migrateV3(),
},
];
}
@@ -554,6 +581,26 @@ export class StatsDB {
logger.debug('Added is_remote column to query_events table', LOG_CONTEXT);
}
/**
* Migration v3: Add session_lifecycle table for tracking session creation and closure
*
* This enables tracking of unique sessions launched over time, session duration,
* and session lifecycle metrics in the Usage Dashboard.
*/
private migrateV3(): void {
if (!this.db) throw new Error('Database not initialized');
// Create session_lifecycle table
this.db.prepare(CREATE_SESSION_LIFECYCLE_SQL).run();
// Create indexes
for (const indexSql of CREATE_SESSION_LIFECYCLE_INDEXES_SQL.split(';').filter((s) => s.trim())) {
this.db.prepare(indexSql).run();
}
logger.debug('Created session_lifecycle table', LOG_CONTEXT);
}
// ============================================================================
// Database Lifecycle
// ============================================================================
@@ -1133,6 +1180,100 @@ export class StatsDB {
}));
}
// ============================================================================
// Session Lifecycle
// ============================================================================
/**
* Record a session being created (launched)
*/
recordSessionCreated(event: Omit<SessionLifecycleEvent, 'id' | 'closedAt' | 'duration'>): string {
if (!this.db) throw new Error('Database not initialized');
const id = generateId();
const stmt = this.db.prepare(`
INSERT INTO session_lifecycle (id, session_id, agent_type, project_path, created_at, is_remote)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(
id,
event.sessionId,
event.agentType,
normalizePath(event.projectPath),
event.createdAt,
event.isRemote !== undefined ? (event.isRemote ? 1 : 0) : null
);
logger.debug(`Recorded session created: ${event.sessionId}`, LOG_CONTEXT);
return id;
}
/**
* Record a session being closed
*/
recordSessionClosed(sessionId: string, closedAt: number): boolean {
if (!this.db) throw new Error('Database not initialized');
// Get the session's created_at time to calculate duration
const session = this.db.prepare(`
SELECT created_at FROM session_lifecycle WHERE session_id = ?
`).get(sessionId) as { created_at: number } | undefined;
if (!session) {
logger.debug(`Session not found for closure: ${sessionId}`, LOG_CONTEXT);
return false;
}
const duration = closedAt - session.created_at;
const stmt = this.db.prepare(`
UPDATE session_lifecycle
SET closed_at = ?, duration = ?
WHERE session_id = ?
`);
const result = stmt.run(closedAt, duration, sessionId);
logger.debug(`Recorded session closed: ${sessionId}, duration: ${duration}ms`, LOG_CONTEXT);
return result.changes > 0;
}
/**
* Get session lifecycle events within a time range
*/
getSessionLifecycleEvents(range: StatsTimeRange): SessionLifecycleEvent[] {
if (!this.db) throw new Error('Database not initialized');
const startTime = getTimeRangeStart(range);
const stmt = this.db.prepare(`
SELECT * FROM session_lifecycle
WHERE created_at >= ?
ORDER BY created_at DESC
`);
const rows = stmt.all(startTime) as Array<{
id: string;
session_id: string;
agent_type: string;
project_path: string | null;
created_at: number;
closed_at: number | null;
duration: number | null;
is_remote: number | null;
}>;
return rows.map((row) => ({
id: row.id,
sessionId: row.session_id,
agentType: row.agent_type,
projectPath: row.project_path ?? undefined,
createdAt: row.created_at,
closedAt: row.closed_at ?? undefined,
duration: row.duration ?? undefined,
isRemote: row.is_remote !== null ? row.is_remote === 1 : undefined,
}));
}
// ============================================================================
// Aggregations
// ============================================================================
@@ -1246,6 +1387,49 @@ export class StatsDB {
}>;
perfMetrics.end(byHourStart, 'getAggregatedStats:byHour', { range });
// Session lifecycle stats
const sessionsStart = perfMetrics.start();
// Total sessions and average duration
const sessionTotalsStmt = this.db.prepare(`
SELECT COUNT(*) as count, COALESCE(AVG(duration), 0) as avg_duration
FROM session_lifecycle
WHERE created_at >= ?
`);
const sessionTotals = sessionTotalsStmt.get(startTime) as { count: number; avg_duration: number };
// Sessions by agent type
const sessionsByAgentStmt = this.db.prepare(`
SELECT agent_type, COUNT(*) as count
FROM session_lifecycle
WHERE created_at >= ?
GROUP BY agent_type
`);
const sessionsByAgentRows = sessionsByAgentStmt.all(startTime) as Array<{
agent_type: string;
count: number;
}>;
const sessionsByAgent: Record<string, number> = {};
for (const row of sessionsByAgentRows) {
sessionsByAgent[row.agent_type] = row.count;
}
// Sessions by day
const sessionsByDayStmt = this.db.prepare(`
SELECT date(created_at / 1000, 'unixepoch', 'localtime') as date,
COUNT(*) as count
FROM session_lifecycle
WHERE created_at >= ?
GROUP BY date(created_at / 1000, 'unixepoch', 'localtime')
ORDER BY date ASC
`);
const sessionsByDayRows = sessionsByDayStmt.all(startTime) as Array<{
date: string;
count: number;
}>;
perfMetrics.end(sessionsStart, 'getAggregatedStats:sessions', { range, sessionCount: sessionTotals.count });
const totalDuration = perfMetrics.end(perfStart, 'getAggregatedStats:total', {
range,
totalQueries: totals.count,
@@ -1269,6 +1453,10 @@ export class StatsDB {
byDay: byDayRows,
byLocation,
byHour: byHourRows,
totalSessions: sessionTotals.count,
sessionsByAgent,
sessionsByDay: sessionsByDayRows,
avgSessionDuration: Math.round(sessionTotals.avg_duration),
};
}
@@ -1279,9 +1467,9 @@ export class StatsDB {
/**
* Clear old data from the database.
*
* Deletes query_events, auto_run_sessions, and auto_run_tasks that are older
* than the specified number of days. This is useful for managing database size
* and removing stale historical data.
* Deletes query_events, auto_run_sessions, auto_run_tasks, and session_lifecycle
* records that are older than the specified number of days. This is useful for
* managing database size and removing stale historical data.
*
* @param olderThanDays - Delete records older than this many days (e.g., 30, 90, 180, 365)
* @returns Object with success status, number of records deleted from each table, and any error
@@ -1291,6 +1479,7 @@ export class StatsDB {
deletedQueryEvents: number;
deletedAutoRunSessions: number;
deletedAutoRunTasks: number;
deletedSessionLifecycle: number;
error?: string;
} {
if (!this.db) {
@@ -1299,6 +1488,7 @@ export class StatsDB {
deletedQueryEvents: 0,
deletedAutoRunSessions: 0,
deletedAutoRunTasks: 0,
deletedSessionLifecycle: 0,
error: 'Database not initialized',
};
}
@@ -1309,6 +1499,7 @@ export class StatsDB {
deletedQueryEvents: 0,
deletedAutoRunSessions: 0,
deletedAutoRunTasks: 0,
deletedSessionLifecycle: 0,
error: 'olderThanDays must be greater than 0',
};
}
@@ -1351,9 +1542,15 @@ export class StatsDB {
.run(cutoffTime);
const deletedEvents = eventsResult.changes;
const totalDeleted = deletedEvents + deletedSessions + deletedTasks;
// Delete session_lifecycle
const lifecycleResult = this.db
.prepare('DELETE FROM session_lifecycle WHERE created_at < ?')
.run(cutoffTime);
const deletedLifecycle = lifecycleResult.changes;
const totalDeleted = deletedEvents + deletedSessions + deletedTasks + deletedLifecycle;
logger.info(
`Cleared ${totalDeleted} old stats records (${deletedEvents} query events, ${deletedSessions} auto-run sessions, ${deletedTasks} auto-run tasks)`,
`Cleared ${totalDeleted} old stats records (${deletedEvents} query events, ${deletedSessions} auto-run sessions, ${deletedTasks} auto-run tasks, ${deletedLifecycle} session lifecycle)`,
LOG_CONTEXT
);
@@ -1362,6 +1559,7 @@ export class StatsDB {
deletedQueryEvents: deletedEvents,
deletedAutoRunSessions: deletedSessions,
deletedAutoRunTasks: deletedTasks,
deletedSessionLifecycle: deletedLifecycle,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -1371,6 +1569,7 @@ export class StatsDB {
deletedQueryEvents: 0,
deletedAutoRunSessions: 0,
deletedAutoRunTasks: 0,
deletedSessionLifecycle: 0,
error: errorMessage,
};
}

View File

@@ -5634,6 +5634,9 @@ You are taking over this conversation. Based on the context above, provide a bri
showConfirmation(
`Are you sure you want to delete the agent "${session.name}"? This action cannot be undone.`,
async () => {
// Record session closure for Usage Dashboard (before cleanup)
window.maestro.stats.recordSessionClosed(id, Date.now());
// Kill both processes for this session
try {
await window.maestro.process.kill(`${id}-ai`);
@@ -5873,6 +5876,14 @@ You are taking over this conversation. Based on the context above, provide a bri
setActiveSessionId(newId);
// Track session creation in global stats
updateGlobalStats({ totalSessions: 1 });
// Record session lifecycle for Usage Dashboard
window.maestro.stats.recordSessionCreated({
sessionId: newId,
agentType: agentId,
projectPath: workingDir,
createdAt: Date.now(),
isRemote: !!isRemoteSession,
});
// Auto-focus the input so user can start typing immediately
// Use a small delay to ensure the modal has closed and the UI has updated
setActiveFocus('main');
@@ -6007,6 +6018,14 @@ You are taking over this conversation. Based on the context above, provide a bri
setSessions(prev => [...prev, newSession]);
setActiveSessionId(newId);
updateGlobalStats({ totalSessions: 1 });
// Record session lifecycle for Usage Dashboard
window.maestro.stats.recordSessionCreated({
sessionId: newId,
agentType: selectedAgent,
projectPath: directoryPath,
createdAt: Date.now(),
isRemote: !!sessionSshRemoteConfig?.enabled,
});
// Clear wizard resume state since we completed successfully
clearResumeState();

View File

@@ -24,6 +24,7 @@ import {
Timer,
Bot,
Users,
Layers,
} from 'lucide-react';
import type { Theme } from '../../types';
import type { StatsAggregation } from '../../hooks/useStats';
@@ -33,7 +34,7 @@ interface SummaryCardsProps {
data: StatsAggregation;
/** Current theme for styling */
theme: Theme;
/** Number of columns for responsive layout (default: 5) */
/** Number of columns for responsive layout (default: 6) */
columns?: number;
}
@@ -124,7 +125,7 @@ function MetricCard({ icon, label, value, theme, animationIndex = 0 }: MetricCar
);
}
export function SummaryCards({ data, theme, columns = 5 }: SummaryCardsProps) {
export function SummaryCards({ data, theme, columns = 6 }: SummaryCardsProps) {
// Calculate derived metrics
const { mostActiveAgent, interactiveRatio } = useMemo(() => {
// Find most active agent by query count
@@ -146,6 +147,11 @@ export function SummaryCards({ data, theme, columns = 5 }: SummaryCardsProps) {
}, [data.byAgent, data.bySource]);
const metrics = [
{
icon: <Layers className="w-4 h-4" />,
label: 'Sessions',
value: formatNumber(data.totalSessions),
},
{
icon: <MessageSquare className="w-4 h-4" />,
label: 'Total Queries',

View File

@@ -57,6 +57,11 @@ interface StatsAggregation {
byLocation: { local: number; remote: number };
byDay: Array<{ date: string; count: number; duration: number }>;
byHour: Array<{ hour: number; count: number; duration: number }>;
// Session lifecycle stats
totalSessions: number;
sessionsByAgent: Record<string, number>;
sessionsByDay: Array<{ date: string; count: number }>;
avgSessionDuration: number;
}
// View mode options for the dashboard

View File

@@ -1647,6 +1647,10 @@ interface MaestroAPI {
byLocation: { local: number; remote: number };
byDay: Array<{ date: string; count: number; duration: number }>;
byHour: Array<{ hour: number; count: number; duration: number }>;
totalSessions: number;
sessionsByAgent: Record<string, number>;
sessionsByDay: Array<{ date: string; count: number }>;
avgSessionDuration: number;
}>;
// Export query events to CSV
exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<string>;
@@ -1658,10 +1662,32 @@ interface MaestroAPI {
deletedQueryEvents: number;
deletedAutoRunSessions: number;
deletedAutoRunTasks: number;
deletedSessionLifecycle: number;
error?: string;
}>;
// Get database size in bytes
getDatabaseSize: () => Promise<number>;
// Record session creation (launched)
recordSessionCreated: (event: {
sessionId: string;
agentType: string;
projectPath?: string;
createdAt: number;
isRemote?: boolean;
}) => Promise<string | null>;
// Record session closure
recordSessionClosed: (sessionId: string, closedAt: number) => Promise<boolean>;
// Get session lifecycle events within a time range
getSessionLifecycle: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<Array<{
id: string;
sessionId: string;
agentType: string;
projectPath?: string;
createdAt: number;
closedAt?: number;
duration?: number;
isRemote?: boolean;
}>>;
};
// Document Graph API (file watching for graph visualization)
documentGraph: {

View File

@@ -29,6 +29,11 @@ export interface StatsAggregation {
byLocation: { local: number; remote: number };
byDay: Array<{ date: string; count: number; duration: number }>;
byHour: Array<{ hour: number; count: number; duration: number }>;
// Session lifecycle stats
totalSessions: number;
sessionsByAgent: Record<string, number>;
sessionsByDay: Array<{ date: string; count: number }>;
avgSessionDuration: number;
}
// Return type for the useStats hook

View File

@@ -50,6 +50,22 @@ export interface AutoRunTask {
success: boolean;
}
/**
* Session lifecycle event - tracks when sessions are created and closed
*/
export interface SessionLifecycleEvent {
id: string;
sessionId: string;
agentType: string;
projectPath?: string;
createdAt: number;
closedAt?: number;
/** Duration in ms (computed from closedAt - createdAt when session is closed) */
duration?: number;
/** Whether this was a remote SSH session */
isRemote?: boolean;
}
/**
* Time range for querying stats
*/
@@ -69,6 +85,14 @@ export interface StatsAggregation {
byLocation: { local: number; remote: number };
/** Breakdown by hour of day (0-23) for peak hours chart */
byHour: Array<{ hour: number; count: number; duration: number }>;
/** Total unique sessions launched in the time period */
totalSessions: number;
/** Sessions by agent type */
sessionsByAgent: Record<string, number>;
/** Sessions launched per day */
sessionsByDay: Array<{ date: string; count: number }>;
/** Average session duration in ms (for closed sessions) */
avgSessionDuration: number;
}
/**
@@ -84,4 +108,4 @@ export interface StatsFilters {
/**
* Database schema version for migrations
*/
export const STATS_DB_VERSION = 2;
export const STATS_DB_VERSION = 3;