diff --git a/package.json b/package.json
index 44ddfaee..91373d80 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/__tests__/main/ipc/handlers/stats.test.ts b/src/__tests__/main/ipc/handlers/stats.test.ts
index 70e0e99d..40db10cc 100644
--- a/src/__tests__/main/ipc/handlers/stats.test.ts
+++ b/src/__tests__/main/ipc/handlers/stats.test.ts
@@ -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();
+ });
+ });
+ });
});
diff --git a/src/__tests__/main/stats-db.test.ts b/src/__tests__/main/stats-db.test.ts
index 97ea85cf..bff230aa 100644
--- a/src/__tests__/main/stats-db.test.ts
+++ b/src/__tests__/main/stats-db.test.ts
@@ -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);
diff --git a/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx b/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx
index 13f9f608..25d45fff 100644
--- a/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboard/SummaryCards.test.tsx
@@ -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();
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();
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', () => {
diff --git a/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx b/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx
index b7af1554..78c1b103 100644
--- a/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboard/chart-accessibility.test.tsx
@@ -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();
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();
diff --git a/src/__tests__/renderer/components/UsageDashboard/responsive-layout.test.tsx b/src/__tests__/renderer/components/UsageDashboard/responsive-layout.test.tsx
index d6b5ade8..b176ff49 100644
--- a/src/__tests__/renderer/components/UsageDashboard/responsive-layout.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboard/responsive-layout.test.tsx
@@ -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(
);
@@ -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);
});
});
diff --git a/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx b/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx
index 37f26d6f..7cb5d5f7 100644
--- a/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboard/state-transition-animations.test.tsx
@@ -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();
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();
const cards = screen.getAllByTestId('metric-card');
- expect(cards[4]).toHaveStyle({ animationDelay: '200ms' });
+ expect(cards[5]).toHaveStyle({ animationDelay: '250ms' });
});
});
diff --git a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
index 0fabacc6..4f6cf24d 100644
--- a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
@@ -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', () => {
diff --git a/src/main/ipc/handlers/stats.ts b/src/main/ipc/handlers/stats.ts
index b7171d4e..52156254 100644
--- a/src/main/ipc/handlers/stats.ts
+++ b/src/main/ipc/handlers/stats.ts
@@ -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) => {
+ // 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);
+ })
+ );
}
diff --git a/src/main/stats-db.ts b/src/main/stats-db.ts
index 3362890f..c6bc213a 100644
--- a/src/main/stats-db.ts
+++ b/src/main/stats-db.ts
@@ -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): 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 = {};
+ 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,
};
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 6adfc9a9..bcf23122 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -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();
diff --git a/src/renderer/components/UsageDashboard/SummaryCards.tsx b/src/renderer/components/UsageDashboard/SummaryCards.tsx
index 0ef166ef..1f8d5172 100644
--- a/src/renderer/components/UsageDashboard/SummaryCards.tsx
+++ b/src/renderer/components/UsageDashboard/SummaryCards.tsx
@@ -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: ,
+ label: 'Sessions',
+ value: formatNumber(data.totalSessions),
+ },
{
icon: ,
label: 'Total Queries',
diff --git a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx
index 8f6a5efe..f4c15e3a 100644
--- a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx
+++ b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx
@@ -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;
+ sessionsByDay: Array<{ date: string; count: number }>;
+ avgSessionDuration: number;
}
// View mode options for the dashboard
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index f24f9b97..3d6b5f95 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -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;
+ sessionsByDay: Array<{ date: string; count: number }>;
+ avgSessionDuration: number;
}>;
// Export query events to CSV
exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise;
@@ -1658,10 +1662,32 @@ interface MaestroAPI {
deletedQueryEvents: number;
deletedAutoRunSessions: number;
deletedAutoRunTasks: number;
+ deletedSessionLifecycle: number;
error?: string;
}>;
// Get database size in bytes
getDatabaseSize: () => Promise;
+ // Record session creation (launched)
+ recordSessionCreated: (event: {
+ sessionId: string;
+ agentType: string;
+ projectPath?: string;
+ createdAt: number;
+ isRemote?: boolean;
+ }) => Promise;
+ // Record session closure
+ recordSessionClosed: (sessionId: string, closedAt: number) => Promise;
+ // Get session lifecycle events within a time range
+ getSessionLifecycle: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise>;
};
// Document Graph API (file watching for graph visualization)
documentGraph: {
diff --git a/src/renderer/hooks/useStats.ts b/src/renderer/hooks/useStats.ts
index d4721aaf..5a95899b 100644
--- a/src/renderer/hooks/useStats.ts
+++ b/src/renderer/hooks/useStats.ts
@@ -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;
+ sessionsByDay: Array<{ date: string; count: number }>;
+ avgSessionDuration: number;
}
// Return type for the useStats hook
diff --git a/src/shared/stats-types.ts b/src/shared/stats-types.ts
index 3d6c07bf..bb4dfdd7 100644
--- a/src/shared/stats-types.ts
+++ b/src/shared/stats-types.ts
@@ -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;
+ /** 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;