diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index ffce8484e4..72b445a5fc 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -31,6 +31,13 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo } cleanups = append(cleanups, cleanup) + // Workflow repo must be initialized before analytics repo because + // analytics creates indexes on the workflow_steps table. + workflowRepo, err := workflowrepository.NewWithDB(dbConn) + if err != nil { + return nil, nil, nil, err + } + analyticsRepo, cleanup, err := analyticsrepository.Provide(dbConn) if err != nil { return nil, nil, nil, err @@ -67,11 +74,6 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo } cleanups = append(cleanups, cleanup) - workflowRepo, err := workflowrepository.NewWithDB(dbConn) - if err != nil { - return nil, nil, nil, err - } - repos := &Repositories{ Task: taskRepoImpl, Analytics: analyticsRepo, diff --git a/apps/backend/internal/analytics/handlers/stats_handlers.go b/apps/backend/internal/analytics/handlers/stats_handlers.go index 895f09066c..2ff7927bdc 100644 --- a/apps/backend/internal/analytics/handlers/stats_handlers.go +++ b/apps/backend/internal/analytics/handlers/stats_handlers.go @@ -12,6 +12,9 @@ import ( "go.uber.org/zap" ) +// allTimeActivityDays is the number of days shown in the daily activity heatmap for the "all" range. +const allTimeActivityDays = 365 + type StatsHandlers struct { repo repository.Repository logger *logger.Logger @@ -213,6 +216,8 @@ func parseStatsRange(rangeKey string) (*time.Time, int) { case "month": start := now.AddDate(0, 0, -30) return &start, 30 + case "all": + return nil, allTimeActivityDays default: start := now.AddDate(0, 0, -30) return &start, 30 diff --git a/apps/backend/internal/analytics/handlers/stats_handlers_test.go b/apps/backend/internal/analytics/handlers/stats_handlers_test.go new file mode 100644 index 0000000000..5bf3aadd53 --- /dev/null +++ b/apps/backend/internal/analytics/handlers/stats_handlers_test.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "testing" + "time" +) + +func TestParseStatsRange(t *testing.T) { + tests := []struct { + name string + rangeKey string + wantNilStart bool + wantDays int + }{ + { + name: "week returns 7 days", + rangeKey: "week", + wantNilStart: false, + wantDays: 7, + }, + { + name: "month returns 30 days", + rangeKey: "month", + wantNilStart: false, + wantDays: 30, + }, + { + name: "all returns nil start and allTimeActivityDays", + rangeKey: "all", + wantNilStart: true, + wantDays: allTimeActivityDays, + }, + { + name: "empty string defaults to month", + rangeKey: "", + wantNilStart: false, + wantDays: 30, + }, + { + name: "unknown value defaults to month", + rangeKey: "year", + wantNilStart: false, + wantDays: 30, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, days := parseStatsRange(tt.rangeKey) + + if tt.wantNilStart { + if start != nil { + t.Errorf("expected nil start, got %v", start) + } + } else { + if start == nil { + t.Fatal("expected non-nil start, got nil") + } + // Verify start is approximately the right number of days ago + expectedStart := time.Now().UTC().AddDate(0, 0, -tt.wantDays) + diff := start.Sub(expectedStart) + if diff < -time.Second || diff > time.Second { + t.Errorf("expected start ~%v, got %v (diff: %v)", expectedStart, start, diff) + } + } + + if days != tt.wantDays { + t.Errorf("expected days=%d, got %d", tt.wantDays, days) + } + }) + } +} diff --git a/apps/backend/internal/analytics/repository/sqlite/repository.go b/apps/backend/internal/analytics/repository/sqlite/repository.go index 5faba38eb7..748d1187be 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository.go @@ -9,11 +9,88 @@ type Repository struct { } // NewWithDB creates a new analytics repository with an existing database connection. +// It automatically creates performance indexes for stats queries. func NewWithDB(dbConn *sql.DB) (*Repository, error) { - return &Repository{db: dbConn}, nil + repo := &Repository{db: dbConn} + if err := repo.ensureStatsIndexes(); err != nil { + return nil, err + } + return repo, nil } // Close is a no-op because this repository does not own the database connection. func (r *Repository) Close() error { return nil } + +// ensureStatsIndexes creates performance indexes for stats queries if they don't already exist. +// Query planner statistics are maintained by PRAGMA optimize on connection close (see persistence/provider.go). +func (r *Repository) ensureStatsIndexes() error { + indexes := []string{ + // Tasks table indexes + `CREATE INDEX IF NOT EXISTS idx_tasks_workspace_created + ON tasks(workspace_id, created_at)`, + + `CREATE INDEX IF NOT EXISTS idx_tasks_workflow_state + ON tasks(workflow_step_id, state)`, + + `CREATE INDEX IF NOT EXISTS idx_tasks_updated + ON tasks(workspace_id, updated_at)`, + + // Task sessions indexes + `CREATE INDEX IF NOT EXISTS idx_sessions_task_started + ON task_sessions(task_id, started_at)`, + + `CREATE INDEX IF NOT EXISTS idx_sessions_agent_started + ON task_sessions(agent_profile_id, started_at)`, + + `CREATE INDEX IF NOT EXISTS idx_sessions_repo + ON task_sessions(repository_id)`, + + // Task session turns indexes + `CREATE INDEX IF NOT EXISTS idx_turns_session_times + ON task_session_turns(task_session_id, started_at, completed_at)`, + + `CREATE INDEX IF NOT EXISTS idx_turns_task + ON task_session_turns(task_id)`, + + // Task session messages indexes + `CREATE INDEX IF NOT EXISTS idx_messages_session_created + ON task_session_messages(task_session_id, created_at)`, + + `CREATE INDEX IF NOT EXISTS idx_messages_author_type + ON task_session_messages(task_session_id, author_type, type)`, + + `CREATE INDEX IF NOT EXISTS idx_messages_turn + ON task_session_messages(turn_id)`, + + // Task session commits indexes + `CREATE INDEX IF NOT EXISTS idx_commits_session_time + ON task_session_commits(session_id, committed_at)`, + + // Task repositories indexes + `CREATE INDEX IF NOT EXISTS idx_task_repos_task + ON task_repositories(task_id, repository_id)`, + + `CREATE INDEX IF NOT EXISTS idx_task_repos_repo + ON task_repositories(repository_id, task_id)`, + + // Repositories indexes (with soft delete filter) + `CREATE INDEX IF NOT EXISTS idx_repos_workspace + ON repositories(workspace_id) + WHERE deleted_at IS NULL`, + + // Workflow steps indexes + `CREATE INDEX IF NOT EXISTS idx_workflow_steps_type + ON workflow_steps(id, step_type)`, + } + + for _, idx := range indexes { + if _, err := r.db.Exec(idx); err != nil { + return err + } + } + + return nil +} + diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go new file mode 100644 index 0000000000..1d3c95cdab --- /dev/null +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -0,0 +1,289 @@ +package sqlite + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/kandev/kandev/internal/db" +) + +// createTestDB sets up a SQLite database with all required schemas for analytics queries. +func createTestDB(t *testing.T) *sql.DB { + t.Helper() + tmpDir := t.TempDir() + dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + t.Cleanup(func() { _ = dbConn.Close() }) + + // Create tables that analytics queries depend on + schema := ` + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + name TEXT NOT NULL, + step_type TEXT NOT NULL, + position INTEGER NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + board_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + state TEXT DEFAULT 'TODO', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS repositories ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'local', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME + ); + CREATE TABLE IF NOT EXISTS task_repositories ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(task_id, repository_id) + ); + CREATE TABLE IF NOT EXISTS task_sessions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + agent_profile_id TEXT NOT NULL, + agent_profile_snapshot TEXT DEFAULT '{}', + repository_id TEXT DEFAULT '', + state TEXT NOT NULL DEFAULT 'CREATED', + started_at DATETIME NOT NULL, + completed_at DATETIME, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_session_turns ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + started_at DATETIME NOT NULL, + completed_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_session_messages ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + author_type TEXT NOT NULL DEFAULT 'user', + type TEXT NOT NULL DEFAULT 'message', + content TEXT NOT NULL, + created_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_session_commits ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + committed_at DATETIME NOT NULL, + files_changed INTEGER DEFAULT 0, + insertions INTEGER DEFAULT 0, + deletions INTEGER DEFAULT 0, + created_at DATETIME NOT NULL + ); + ` + if _, err := dbConn.Exec(schema); err != nil { + t.Fatalf("failed to create schema: %v", err) + } + return dbConn +} + +func execOrFatal(t *testing.T, dbConn *sql.DB, query string, args ...any) { + t.Helper() + if _, err := dbConn.Exec(query, args...); err != nil { + t.Fatalf("exec failed: %v", err) + } +} + +func TestEnsureStatsIndexes_CreatesIndexes(t *testing.T) { + dbConn := createTestDB(t) + + repo, err := NewWithDB(dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + // Verify indexes were created + var count int + err = dbConn.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'`, + ).Scan(&count) + if err != nil { + t.Fatalf("failed to count indexes: %v", err) + } + if count == 0 { + t.Error("expected indexes to be created, got 0") + } + + _ = repo // keep reference +} + +func TestEnsureStatsIndexes_Idempotent(t *testing.T) { + dbConn := createTestDB(t) + + repo := &Repository{db: dbConn} + if err := repo.ensureStatsIndexes(); err != nil { + t.Fatalf("first ensureStatsIndexes failed: %v", err) + } + + // Calling again should not error (CREATE INDEX IF NOT EXISTS is idempotent) + if err := repo.ensureStatsIndexes(); err != nil { + t.Fatalf("second ensureStatsIndexes failed: %v", err) + } +} + +func TestGetRepositoryStats_ExcludesSoftDeletedRepos(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC().Format(time.RFC3339) + + // Insert workspace + if _, err := dbConn.Exec( + `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`, + "ws-1", "Test Workspace", now, now, + ); err != nil { + t.Fatalf("failed to insert workspace: %v", err) + } + + // Insert an active repo + if _, err := dbConn.Exec( + `INSERT INTO repositories (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + "repo-active", "ws-1", "active-repo", now, now, + ); err != nil { + t.Fatalf("failed to insert active repo: %v", err) + } + + // Insert a soft-deleted repo + if _, err := dbConn.Exec( + `INSERT INTO repositories (id, workspace_id, name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?)`, + "repo-deleted", "ws-1", "deleted-repo", now, now, now, + ); err != nil { + t.Fatalf("failed to insert deleted repo: %v", err) + } + + results, err := repo.GetRepositoryStats(ctx, "ws-1", nil) + if err != nil { + t.Fatalf("GetRepositoryStats failed: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 repository, got %d", len(results)) + } + if results[0].RepositoryID != "repo-active" { + t.Errorf("expected repo-active, got %s", results[0].RepositoryID) + } +} + +func TestGetGlobalStats_EmptyWorkspace(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + stats, err := repo.GetGlobalStats(ctx, "nonexistent", nil) + if err != nil { + t.Fatalf("GetGlobalStats failed: %v", err) + } + + if stats.TotalTasks != 0 { + t.Errorf("expected 0 total tasks, got %d", stats.TotalTasks) + } + if stats.TotalSessions != 0 { + t.Errorf("expected 0 total sessions, got %d", stats.TotalSessions) + } +} + +func TestGetGlobalStats_WithTimeFilter(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + oldStr := now.AddDate(0, 0, -60).Format(time.RFC3339) + + // Insert workspace + board + workflow step + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO workflow_steps (id, board_id, name, step_type, position, created_at, updated_at) VALUES ('step-todo', 'board-1', 'To Do', 'todo', 0, ?, ?)`, nowStr, nowStr) + + // Insert a recent task and an old task + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, created_at, updated_at) VALUES ('task-recent', 'ws-1', 'board-1', 'step-todo', 'Recent', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, created_at, updated_at) VALUES ('task-old', 'ws-1', 'board-1', 'step-todo', 'Old', ?, ?)`, oldStr, oldStr) + + // With no time filter — should see both + stats, err := repo.GetGlobalStats(ctx, "ws-1", nil) + if err != nil { + t.Fatalf("GetGlobalStats (no filter) failed: %v", err) + } + if stats.TotalTasks != 2 { + t.Errorf("expected 2 total tasks (no filter), got %d", stats.TotalTasks) + } + + // With time filter for last 7 days — should see only the recent one + weekAgo := now.AddDate(0, 0, -7) + stats, err = repo.GetGlobalStats(ctx, "ws-1", &weekAgo) + if err != nil { + t.Fatalf("GetGlobalStats (week filter) failed: %v", err) + } + if stats.TotalTasks != 1 { + t.Errorf("expected 1 total task (week filter), got %d", stats.TotalTasks) + } +} + +func TestGetGitStats_EmptyWorkspace(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + stats, err := repo.GetGitStats(ctx, "nonexistent", nil) + if err != nil { + t.Fatalf("GetGitStats failed: %v", err) + } + + if stats.TotalCommits != 0 { + t.Errorf("expected 0 commits, got %d", stats.TotalCommits) + } +} diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 5089014260..7467fdf537 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -410,6 +410,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, GROUP BY s.repository_id ) git_stats ON git_stats.repository_id = r.id WHERE r.workspace_id = ? + AND r.deleted_at IS NULL ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC ` diff --git a/apps/backend/internal/persistence/provider.go b/apps/backend/internal/persistence/provider.go index ab7965e87e..c6d3eab5d0 100644 --- a/apps/backend/internal/persistence/provider.go +++ b/apps/backend/internal/persistence/provider.go @@ -35,7 +35,14 @@ func Provide(cfg *config.Config, log *logger.Logger) (*sql.DB, func() error, err if log != nil { log.Info("Database initialized", zap.String("db_path", dbPath), zap.String("db_driver", driver)) } - return dbConn, func() error { return dbConn.Close() }, nil + cleanup := func() error { + // Run PRAGMA optimize before closing to update query planner + // statistics for tables that need it. This is the SQLite-recommended + // way to maintain stats — lightweight and safe to call on every close. + _, _ = dbConn.Exec("PRAGMA optimize") + return dbConn.Close() + } + return dbConn, cleanup, nil default: return nil, nil, fmt.Errorf("unsupported database driver: %s", driver) } diff --git a/apps/web/app/stats/loading.tsx b/apps/web/app/stats/loading.tsx new file mode 100644 index 0000000000..2933447982 --- /dev/null +++ b/apps/web/app/stats/loading.tsx @@ -0,0 +1,154 @@ +import { Card, CardContent, CardHeader } from '@kandev/ui/card'; +import { Skeleton } from '@kandev/ui/skeleton'; + +export default function StatsLoading() { + return ( +