From 66b2895d90a78e0ea5eada47571fc2158033ade4 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 2 Jan 2026 14:37:38 -0600 Subject: [PATCH] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 πŸŽ‰ --- package.json | 2 +- src/__tests__/main/ipc/handlers/stats.test.ts | 121 ++++++++++ src/__tests__/main/stats-db.test.ts | 99 ++++++++- .../UsageDashboard/SummaryCards.test.tsx | 45 +++- .../chart-accessibility.test.tsx | 22 +- .../UsageDashboard/responsive-layout.test.tsx | 33 ++- .../state-transition-animations.test.tsx | 28 ++- .../components/UsageDashboardModal.test.tsx | 21 ++ src/main/ipc/handlers/stats.ts | 51 +++++ src/main/stats-db.ts | 209 +++++++++++++++++- src/renderer/App.tsx | 19 ++ .../UsageDashboard/SummaryCards.tsx | 10 +- .../UsageDashboard/UsageDashboardModal.tsx | 5 + src/renderer/global.d.ts | 26 +++ src/renderer/hooks/useStats.ts | 5 + src/shared/stats-types.ts | 26 ++- 16 files changed, 686 insertions(+), 36 deletions(-) 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;